HERMES-GIELIE/tests/test_issue_new_chat_draft_restore.py

186 lines
9.7 KiB
Python
Raw Normal View History

Release v0.51.241 — Release HI (stage-q13) (#3498) ## Release v0.51.241 — Release HI (stage-q13) UX-flow bug-fix — approved via Telegram. ### Fixed | PR | Author | Fix | |----|--------|-----| | #3471 (#3333) | @starGazerK | **New Chat keeps your unsent draft after peeking at history.** Start a New Chat draft → open a previous conversation → click New Chat: the draft is no longer lost. Empty New-Chat sessions are hidden from the sidebar, so there was no way back to the session holding the draft — New Chat just created another fresh empty session. The entrypoint now remembers the candidate empty draft session (one `localStorage` pointer) and, before creating a fresh session, re-validates it via `/api/session`, routing back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, non-empty server-side `composer_draft`). | ### Why it's safe for existing installs - When there is no remembered draft, it's a **pure no-op fall-through** to the existing `newSession()` path — no behavior change. - Preserves the "zero-message sessions stay hidden from the sidebar" contract. - Conservative multi-guard validation; the pointer is cleared on draft-clear (after send) so an emptied draft never traps you on New Chat. ### Verified live (end-to-end on a test server) - **Positive**: typed a draft → visited a 2-message history session → clicked New Chat → landed back on the draft session with the text restored. - **Negative**: emptied the draft → New Chat created a fresh session (no accidental trap). ### Absorbed on review (Codex CORE MUST-FIX) The PR added `await _saveComposerDraftNow(...)` before the session-switch, which opened a rapid-switch race: clicking session B then quickly C could let B's stale continuation blank C's freshly-loaded state. Added `if (_loadingSessionId !== sid) return;` immediately after the awaited save and before the destructive state-clear (mirrors the existing #1060 stale-guard) + a regression test pinning the guard's position. ### Gate - Full pytest suite: **7510 passed, 9 skipped, 3 xpassed, 0 failed** - ESLint runtime gate: CLEAN · browser-smoke: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES (rapid-switch race) → fixed → re-reviewed **SAFE TO SHIP** - `tests/test_issue_new_chat_draft_restore.py` (7 assertions incl. the race-guard; live-verified behavior) UX-flow change, no visual/layout delta — approved via Telegram. Co-authored-by: starGazerK <starGazerK@users.noreply.github.com>
2026-06-03 22:24:31 +00:00
"""Regression coverage for New Chat draft-session restoration.
The bug: New Chat -> type draft -> open history -> New Chat created a fresh
empty session instead of returning to the empty session that owns the draft.
"""
from pathlib import Path
ROOT = Path(__file__).parents[1]
SESSIONS_JS = ROOT.joinpath("static", "sessions.js").read_text(encoding="utf-8")
BOOT_JS = ROOT.joinpath("static", "boot.js").read_text(encoding="utf-8")
def _btn_new_chat_handler() -> str:
start = BOOT_JS.find("$('btnNewChat').onclick=async()=>{")
end = BOOT_JS.find("$('btnDownload').onclick", start)
assert start != -1 and end != -1, "btnNewChat handler block not found"
return BOOT_JS[start:end]
def _load_session_clear_block() -> str:
start = SESSIONS_JS.find("async function loadSession(")
clear_start = SESSIONS_JS.find("if (currentSid !== sid || forceReload) {", start)
clear_end = SESSIONS_JS.find("// Phase 1: Load metadata only", clear_start)
assert start != -1, "loadSession not found"
assert clear_start != -1 and clear_end != -1, "loadSession clear block not found"
return SESSIONS_JS[start:clear_end]
Release v0.51.241 — Release HI (stage-q13) (#3498) ## Release v0.51.241 — Release HI (stage-q13) UX-flow bug-fix — approved via Telegram. ### Fixed | PR | Author | Fix | |----|--------|-----| | #3471 (#3333) | @starGazerK | **New Chat keeps your unsent draft after peeking at history.** Start a New Chat draft → open a previous conversation → click New Chat: the draft is no longer lost. Empty New-Chat sessions are hidden from the sidebar, so there was no way back to the session holding the draft — New Chat just created another fresh empty session. The entrypoint now remembers the candidate empty draft session (one `localStorage` pointer) and, before creating a fresh session, re-validates it via `/api/session`, routing back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, non-empty server-side `composer_draft`). | ### Why it's safe for existing installs - When there is no remembered draft, it's a **pure no-op fall-through** to the existing `newSession()` path — no behavior change. - Preserves the "zero-message sessions stay hidden from the sidebar" contract. - Conservative multi-guard validation; the pointer is cleared on draft-clear (after send) so an emptied draft never traps you on New Chat. ### Verified live (end-to-end on a test server) - **Positive**: typed a draft → visited a 2-message history session → clicked New Chat → landed back on the draft session with the text restored. - **Negative**: emptied the draft → New Chat created a fresh session (no accidental trap). ### Absorbed on review (Codex CORE MUST-FIX) The PR added `await _saveComposerDraftNow(...)` before the session-switch, which opened a rapid-switch race: clicking session B then quickly C could let B's stale continuation blank C's freshly-loaded state. Added `if (_loadingSessionId !== sid) return;` immediately after the awaited save and before the destructive state-clear (mirrors the existing #1060 stale-guard) + a regression test pinning the guard's position. ### Gate - Full pytest suite: **7510 passed, 9 skipped, 3 xpassed, 0 failed** - ESLint runtime gate: CLEAN · browser-smoke: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES (rapid-switch race) → fixed → re-reviewed **SAFE TO SHIP** - `tests/test_issue_new_chat_draft_restore.py` (7 assertions incl. the race-guard; live-verified behavior) UX-flow change, no visual/layout delta — approved via Telegram. Co-authored-by: starGazerK <starGazerK@users.noreply.github.com>
2026-06-03 22:24:31 +00:00
def test_new_session_remembers_regular_empty_session_id():
start = SESSIONS_JS.find("async function newSession(")
end = SESSIONS_JS.find("async function loadSession(", start)
assert start != -1 and end != -1, "newSession block not found"
body = SESSIONS_JS[start:end]
assign_idx = body.find("S.session=data.session")
remember_idx = body.find("_rememberNewChatDraftSession(S.session)")
assert assign_idx != -1, "newSession must assign S.session from the POST response"
assert remember_idx > assign_idx, "newSession must remember the created empty session id"
assert "if(!(options&&options.worktree)) _rememberNewChatDraftSession(S.session);" in body, (
"worktree-backed new sessions must not become New Chat draft candidates"
)
def test_new_chat_button_restores_remembered_draft_before_creating_session():
body = _btn_new_chat_handler()
restore_idx = body.find("_restoreRememberedNewChatDraftSession")
new_idx = body.find("await newSession()")
assert restore_idx != -1, "New Chat button must try the remembered draft session first"
assert new_idx != -1, "New Chat button must still fall back to creating a session"
assert restore_idx < new_idx, "draft-session restore must happen before newSession fallback"
assert "return;" in body[restore_idx:new_idx], (
"successful draft-session restore must return instead of also creating a new session"
)
2026-06-29 13:15:54 +00:00
def test_new_chat_empty_reuse_guard_checks_loaded_visible_messages():
helper_start = BOOT_JS.find("function _currentSessionIsReusableEmptyChat()")
helper_end = BOOT_JS.find("$('fileInput').onchange", helper_start)
assert helper_start != -1 and helper_end != -1, "empty-chat reuse helper not found"
helper = BOOT_JS[helper_start:helper_end]
assert "S.session.message_count||0" in helper, (
"empty-chat reuse should still honor the server-side session message count"
)
assert "S.messages.some(m=>m&&m.role&&m.role!=='tool')" in helper, (
"empty-chat reuse must not focus-only when loaded transcript messages are visible"
)
assert "S.session.active_stream_id" in helper and "S.session.pending_user_message" in helper, (
"empty-chat reuse must continue to treat in-flight zero-count sessions as real sessions"
)
button_body = _btn_new_chat_handler()
assert "if(_currentSessionIsReusableEmptyChat())" in button_body
shortcut_start = BOOT_JS.find("if((e.metaKey||e.ctrlKey)&&e.key==='k')")
shortcut_end = BOOT_JS.find("// Cmd/Ctrl+, opens/closes Settings", shortcut_start)
assert shortcut_start != -1 and shortcut_end != -1, "Cmd/Ctrl+K handler block not found"
shortcut_body = BOOT_JS[shortcut_start:shortcut_end]
assert "if(_currentSessionIsReusableEmptyChat())" in shortcut_body
Release v0.51.241 — Release HI (stage-q13) (#3498) ## Release v0.51.241 — Release HI (stage-q13) UX-flow bug-fix — approved via Telegram. ### Fixed | PR | Author | Fix | |----|--------|-----| | #3471 (#3333) | @starGazerK | **New Chat keeps your unsent draft after peeking at history.** Start a New Chat draft → open a previous conversation → click New Chat: the draft is no longer lost. Empty New-Chat sessions are hidden from the sidebar, so there was no way back to the session holding the draft — New Chat just created another fresh empty session. The entrypoint now remembers the candidate empty draft session (one `localStorage` pointer) and, before creating a fresh session, re-validates it via `/api/session`, routing back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, non-empty server-side `composer_draft`). | ### Why it's safe for existing installs - When there is no remembered draft, it's a **pure no-op fall-through** to the existing `newSession()` path — no behavior change. - Preserves the "zero-message sessions stay hidden from the sidebar" contract. - Conservative multi-guard validation; the pointer is cleared on draft-clear (after send) so an emptied draft never traps you on New Chat. ### Verified live (end-to-end on a test server) - **Positive**: typed a draft → visited a 2-message history session → clicked New Chat → landed back on the draft session with the text restored. - **Negative**: emptied the draft → New Chat created a fresh session (no accidental trap). ### Absorbed on review (Codex CORE MUST-FIX) The PR added `await _saveComposerDraftNow(...)` before the session-switch, which opened a rapid-switch race: clicking session B then quickly C could let B's stale continuation blank C's freshly-loaded state. Added `if (_loadingSessionId !== sid) return;` immediately after the awaited save and before the destructive state-clear (mirrors the existing #1060 stale-guard) + a regression test pinning the guard's position. ### Gate - Full pytest suite: **7510 passed, 9 skipped, 3 xpassed, 0 failed** - ESLint runtime gate: CLEAN · browser-smoke: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES (rapid-switch race) → fixed → re-reviewed **SAFE TO SHIP** - `tests/test_issue_new_chat_draft_restore.py` (7 assertions incl. the race-guard; live-verified behavior) UX-flow change, no visual/layout delta — approved via Telegram. Co-authored-by: starGazerK <starGazerK@users.noreply.github.com>
2026-06-03 22:24:31 +00:00
def test_restore_helper_validates_candidate_with_session_metadata():
assert "const NEW_CHAT_DRAFT_SESSION_KEY = 'hermes-new-chat-draft-session';" in SESSIONS_JS
assert "async function _restoreRememberedNewChatDraftSession()" in SESSIONS_JS
assert "messages=0&resolve_model=0" in SESSIONS_JS, (
"helper should validate the hidden zero-message candidate through /api/session metadata"
)
assert "_isRestorableNewChatDraftSession(session, true)" in SESSIONS_JS, (
"candidate must have a non-empty server-side composer_draft before restore"
)
assert "await loadSession(sid, {skipLineageResolve:true});" in SESSIONS_JS, (
"helper should load the exact hidden empty draft session"
)
def test_session_switch_awaits_immediate_draft_flush_before_loading_target():
assert "return api('/api/session/draft'" in SESSIONS_JS, (
"_saveComposerDraftNow should return its POST promise so switch-away can await it"
)
assert "await _saveComposerDraftNow(currentSid" in SESSIONS_JS, (
"loadSession must flush the current draft before fetching the next session"
)
def test_immediate_empty_draft_flush_clears_locally_known_server_draft():
assert "const _composerDraftKnownPayloadSessions = new Set();" in SESSIONS_JS
save_start = SESSIONS_JS.find("function _saveComposerDraft(")
save_end = SESSIONS_JS.find("function _composerDraftHasPayload", save_start)
now_start = SESSIONS_JS.find("function _saveComposerDraftNow(")
now_end = SESSIONS_JS.find("// Restore composer draft", now_start)
assert save_start != -1 and save_end != -1
assert now_start != -1 and now_end != -1
save_body = SESSIONS_JS[save_start:save_end]
now_body = SESSIONS_JS[now_start:now_end]
assert "_composerDraftKnownPayloadSessions.add(sid);" in save_body, (
"debounced non-empty draft saves must mark the session as having server-side draft payload"
)
assert "_rememberComposerDraftPayloadState(sid, normalizedText, normalizedFiles);" in save_body
assert "!_composerDraftKnownPayloadSessions.has(sid)" in now_body, (
"empty immediate switch-away saves may only be skipped when no local/server draft payload is known"
)
assert "_rememberComposerDraftPayloadState(sid, normalizedText, normalizedFiles);" in now_body
Release v0.51.241 — Release HI (stage-q13) (#3498) ## Release v0.51.241 — Release HI (stage-q13) UX-flow bug-fix — approved via Telegram. ### Fixed | PR | Author | Fix | |----|--------|-----| | #3471 (#3333) | @starGazerK | **New Chat keeps your unsent draft after peeking at history.** Start a New Chat draft → open a previous conversation → click New Chat: the draft is no longer lost. Empty New-Chat sessions are hidden from the sidebar, so there was no way back to the session holding the draft — New Chat just created another fresh empty session. The entrypoint now remembers the candidate empty draft session (one `localStorage` pointer) and, before creating a fresh session, re-validates it via `/api/session`, routing back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, non-empty server-side `composer_draft`). | ### Why it's safe for existing installs - When there is no remembered draft, it's a **pure no-op fall-through** to the existing `newSession()` path — no behavior change. - Preserves the "zero-message sessions stay hidden from the sidebar" contract. - Conservative multi-guard validation; the pointer is cleared on draft-clear (after send) so an emptied draft never traps you on New Chat. ### Verified live (end-to-end on a test server) - **Positive**: typed a draft → visited a 2-message history session → clicked New Chat → landed back on the draft session with the text restored. - **Negative**: emptied the draft → New Chat created a fresh session (no accidental trap). ### Absorbed on review (Codex CORE MUST-FIX) The PR added `await _saveComposerDraftNow(...)` before the session-switch, which opened a rapid-switch race: clicking session B then quickly C could let B's stale continuation blank C's freshly-loaded state. Added `if (_loadingSessionId !== sid) return;` immediately after the awaited save and before the destructive state-clear (mirrors the existing #1060 stale-guard) + a regression test pinning the guard's position. ### Gate - Full pytest suite: **7510 passed, 9 skipped, 3 xpassed, 0 failed** - ESLint runtime gate: CLEAN · browser-smoke: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES (rapid-switch race) → fixed → re-reviewed **SAFE TO SHIP** - `tests/test_issue_new_chat_draft_restore.py` (7 assertions incl. the race-guard; live-verified behavior) UX-flow change, no visual/layout delta — approved via Telegram. Co-authored-by: starGazerK <starGazerK@users.noreply.github.com>
2026-06-03 22:24:31 +00:00
def test_pre_switch_draft_flush_rechecks_stale_loading_guard():
"""The awaited draft-save in loadSession yields the event loop. On a rapid
session switch (B then quickly C) the stale B continuation must bail out
before the destructive state-clearing block, or it would wipe the
freshly-loaded C state. The guard is `if (_loadingSessionId !== sid) return;`
placed AFTER the awaited save and BEFORE the `S.messages = []` clear
(Codex pre-release CORE catch, #3471)."""
body = _load_session_clear_block()
Release v0.51.241 — Release HI (stage-q13) (#3498) ## Release v0.51.241 — Release HI (stage-q13) UX-flow bug-fix — approved via Telegram. ### Fixed | PR | Author | Fix | |----|--------|-----| | #3471 (#3333) | @starGazerK | **New Chat keeps your unsent draft after peeking at history.** Start a New Chat draft → open a previous conversation → click New Chat: the draft is no longer lost. Empty New-Chat sessions are hidden from the sidebar, so there was no way back to the session holding the draft — New Chat just created another fresh empty session. The entrypoint now remembers the candidate empty draft session (one `localStorage` pointer) and, before creating a fresh session, re-validates it via `/api/session`, routing back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, non-empty server-side `composer_draft`). | ### Why it's safe for existing installs - When there is no remembered draft, it's a **pure no-op fall-through** to the existing `newSession()` path — no behavior change. - Preserves the "zero-message sessions stay hidden from the sidebar" contract. - Conservative multi-guard validation; the pointer is cleared on draft-clear (after send) so an emptied draft never traps you on New Chat. ### Verified live (end-to-end on a test server) - **Positive**: typed a draft → visited a 2-message history session → clicked New Chat → landed back on the draft session with the text restored. - **Negative**: emptied the draft → New Chat created a fresh session (no accidental trap). ### Absorbed on review (Codex CORE MUST-FIX) The PR added `await _saveComposerDraftNow(...)` before the session-switch, which opened a rapid-switch race: clicking session B then quickly C could let B's stale continuation blank C's freshly-loaded state. Added `if (_loadingSessionId !== sid) return;` immediately after the awaited save and before the destructive state-clear (mirrors the existing #1060 stale-guard) + a regression test pinning the guard's position. ### Gate - Full pytest suite: **7510 passed, 9 skipped, 3 xpassed, 0 failed** - ESLint runtime gate: CLEAN · browser-smoke: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES (rapid-switch race) → fixed → re-reviewed **SAFE TO SHIP** - `tests/test_issue_new_chat_draft_restore.py` (7 assertions incl. the race-guard; live-verified behavior) UX-flow change, no visual/layout delta — approved via Telegram. Co-authored-by: starGazerK <starGazerK@users.noreply.github.com>
2026-06-03 22:24:31 +00:00
await_idx = body.find("await _saveComposerDraftNow(currentSid")
guard_idx = body.find("if (_loadingSessionId !== sid) return;", await_idx)
clear_idx = body.find("S.messages = [];", await_idx)
assert await_idx != -1, "pre-switch awaited draft save not found"
assert guard_idx != -1, "stale-loading guard missing after the awaited draft save"
assert clear_idx != -1, "destructive S.messages clear not found"
assert await_idx < guard_idx < clear_idx, (
"the _loadingSessionId stale-guard must sit between the awaited draft "
"save and the destructive state clear so a rapid switch can't blank the "
"newer session"
)
def test_restorable_candidate_rejects_in_flight_worktree_and_cross_profile_sessions():
start = SESSIONS_JS.find("function _isRestorableNewChatDraftSession(")
end = SESSIONS_JS.find("function _rememberNewChatDraftSession", start)
assert start != -1 and end != -1, "restorable candidate helper not found"
body = SESSIONS_JS[start:end]
assert "messageCount !== 0" in body
assert "session.active_stream_id || session.pending_user_message || session.worktree_path" in body
Release v0.51.266 — Release IH (stage-r16) (#3641) ## Release v0.51.266 — Release IH (stage-r16) One agent-authored APPROVED fix + two un-held streaming/SSE fixes. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3635 (#3637) | @nesquena-hermes (nesquena APPROVED) | Composer profile chip reads `S.activeProfile` again — a #3331 regression keyed it on the loaded session's profile, so opening a cross-profile session made the chip disagree with the dropdown checkmark and misrepresent where the next message routes. #3331's project/session-op scoping is unaffected. | | #3587 (#3605) | @rodboev | Reasoning persists to the correct intermediate assistant message in multi-turn tool flows. The index only advanced in `on_interim_assistant` (suppressed for contentless tool-call messages) → post-tool reasoning was mis-attributed; it now also advances at the `on_tool` boundary, guarded against over-increment. **(un-held — finding resolved)** | | #2660 (#3558) | @franksong2702 | Session-event SSE no longer wakes every tab across profiles and never drops a relevant refresh — profile attached when known, root/`default` aliases stay unscoped, and the `maxsize=1` queue falls back to unscoped refresh-all on a profile-mismatch coalesce. **(un-held — both findings resolved)** | ### Gate - Full pytest suite: **7770 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — chip matches dropdown/routing (no #3331 scoping regression), reasoning-index advance composes with the agent's tool/interim callback ordering, session-events coalesce safely with no dropped refresh and no profile data leak (`/api/sessions` still server-side filtered). Co-authored-by: nesquena <nesquena@users.noreply.github.com> Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-05 07:44:03 +00:00
assert "_profileMatchesActiveProfile(sessionProfile, activeProfile)" in body
Release v0.51.241 — Release HI (stage-q13) (#3498) ## Release v0.51.241 — Release HI (stage-q13) UX-flow bug-fix — approved via Telegram. ### Fixed | PR | Author | Fix | |----|--------|-----| | #3471 (#3333) | @starGazerK | **New Chat keeps your unsent draft after peeking at history.** Start a New Chat draft → open a previous conversation → click New Chat: the draft is no longer lost. Empty New-Chat sessions are hidden from the sidebar, so there was no way back to the session holding the draft — New Chat just created another fresh empty session. The entrypoint now remembers the candidate empty draft session (one `localStorage` pointer) and, before creating a fresh session, re-validates it via `/api/session`, routing back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, non-empty server-side `composer_draft`). | ### Why it's safe for existing installs - When there is no remembered draft, it's a **pure no-op fall-through** to the existing `newSession()` path — no behavior change. - Preserves the "zero-message sessions stay hidden from the sidebar" contract. - Conservative multi-guard validation; the pointer is cleared on draft-clear (after send) so an emptied draft never traps you on New Chat. ### Verified live (end-to-end on a test server) - **Positive**: typed a draft → visited a 2-message history session → clicked New Chat → landed back on the draft session with the text restored. - **Negative**: emptied the draft → New Chat created a fresh session (no accidental trap). ### Absorbed on review (Codex CORE MUST-FIX) The PR added `await _saveComposerDraftNow(...)` before the session-switch, which opened a rapid-switch race: clicking session B then quickly C could let B's stale continuation blank C's freshly-loaded state. Added `if (_loadingSessionId !== sid) return;` immediately after the awaited save and before the destructive state-clear (mirrors the existing #1060 stale-guard) + a regression test pinning the guard's position. ### Gate - Full pytest suite: **7510 passed, 9 skipped, 3 xpassed, 0 failed** - ESLint runtime gate: CLEAN · browser-smoke: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES (rapid-switch race) → fixed → re-reviewed **SAFE TO SHIP** - `tests/test_issue_new_chat_draft_restore.py` (7 assertions incl. the race-guard; live-verified behavior) UX-flow change, no visual/layout delta — approved via Telegram. Co-authored-by: starGazerK <starGazerK@users.noreply.github.com>
2026-06-03 22:24:31 +00:00
assert "session.composer_draft || {}" in body
assert "text || files.length" in body
def test_clear_composer_draft_forgets_same_new_chat_candidate():
start = SESSIONS_JS.find("function _clearComposerDraft(")
end = SESSIONS_JS.find("const SESSION_VIEWED_COUNTS_KEY", start)
assert start != -1 and end != -1, "_clearComposerDraft block not found"
body = SESSIONS_JS[start:end]
assert "_clearRememberedNewChatDraftSession(sid);" in body, (
"sending a draft must stop New Chat from restoring that now-cleared candidate"
)
assert "return api('/api/session/draft'" in body, (
"clear path should return its POST promise for callers/tests that need to await it"
)
assert "_rememberComposerDraftPayloadState(sid, '', []);" in body, (
"clear path must also clear local draft-payload tracking so send-then-switch can skip redundant empty flushes"
)
def test_boot_restore_preserves_zero_message_session_with_composer_draft():
"""A hard refresh should not discard a zero-message session that owns unsent draft text/files."""
marker = "const _restoredInFlight = S.session && ("
start = BOOT_JS.find(marker)
end = BOOT_JS.find("// Restore the panel from localStorage", start)
assert start != -1 and end != -1, "boot restored-session cleanup block not found"
body = BOOT_JS[start:end]
assert "const _restoredDraft = (S.session && S.session.composer_draft) || {};" in body
assert "const _restoredDraftText = String(_restoredDraft.text||'').trim();" in body
assert "const _restoredDraftFiles = Array.isArray(_restoredDraft.files)" in body
assert "const _restoredHasDraft = !!(_restoredDraftText || _restoredDraftFiles.length);" in body
assert "&& !_restoredInFlight && !_restoredHasDraft" in body, (
"zero-message restored sessions should only be dropped when they have no draft"
)