mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 14:12:22 +01:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fef4929cf | ||
|
|
651bf714de | ||
|
|
d449a9d431 | ||
|
|
dbeed4b63f | ||
|
|
96aca52094 | ||
|
|
8f2f483725 | ||
|
|
48cf08328f |
+41
-12
@@ -194,7 +194,12 @@ class SessionManager:
|
|||||||
is_important=getattr(db_session, 'is_important', False) or False,
|
is_important=getattr(db_session, 'is_important', False) or False,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.message_count = getattr(db_session, 'message_count', len(history))
|
# The rows just loaded are the whole transcript, so they — not the
|
||||||
|
# denormalized sessions.message_count column — are the truth for this
|
||||||
|
# cached object. get_session's hydration gate compares against this
|
||||||
|
# number; seeding it from a drifted column would ask for a reload that
|
||||||
|
# can never close the gap.
|
||||||
|
session.message_count = len(history)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -398,30 +403,50 @@ class SessionManager:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_session(self, session_id: str) -> Session:
|
def get_session(self, session_id: str) -> Session:
|
||||||
"""Get a session by ID, loading from DB if needed.
|
"""Get a session by ID, loading complete DB history when needed.
|
||||||
|
|
||||||
Sessions seeded by `load_sessions` start with empty history. The
|
Sessions seeded by ``load_sessions`` start with empty history, and a
|
||||||
first read here hydrates them with the message rows.
|
cached session can also become partially stale. Refresh metadata first,
|
||||||
|
then hydrate whenever the cached transcript is short of the stored rows.
|
||||||
|
Model-send routes enter through this method before building context,
|
||||||
|
while paginated display history reads SQLite directly.
|
||||||
|
|
||||||
|
The gate compares against ``sync_session_metadata``'s reconciled count
|
||||||
|
(the real ``chat_messages`` total), never the denormalized column, so a
|
||||||
|
hydrate always closes the gap and the next read is a cache hit.
|
||||||
"""
|
"""
|
||||||
if session_id not in self.sessions:
|
if session_id not in self.sessions:
|
||||||
self._load_session_from_db(session_id)
|
self._load_session_from_db(session_id)
|
||||||
else:
|
|
||||||
cached = self.sessions[session_id]
|
|
||||||
# Lazy hydrate: metadata-only entries get their messages on first read.
|
|
||||||
if not cached.history and getattr(cached, "message_count", 0) > 0:
|
|
||||||
self._load_session_from_db(session_id)
|
|
||||||
|
|
||||||
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
||||||
# DB row while a session object is still cached in RAM.
|
# DB row while a session object is still cached in RAM. Refreshing first
|
||||||
|
# also exposes the authoritative message count before completeness is
|
||||||
|
# checked.
|
||||||
self.sync_session_metadata(session_id)
|
self.sync_session_metadata(session_id)
|
||||||
|
|
||||||
|
cached = self.sessions[session_id]
|
||||||
|
cached_count = len(cached.history or [])
|
||||||
|
stored_count = int(getattr(cached, "message_count", 0) or 0)
|
||||||
|
if cached_count < stored_count:
|
||||||
|
self._load_session_from_db(session_id)
|
||||||
|
|
||||||
# Update last_accessed
|
# Update last_accessed
|
||||||
self._touch_session(session_id)
|
self._touch_session(session_id)
|
||||||
|
|
||||||
return self.sessions[session_id]
|
return self.sessions[session_id]
|
||||||
|
|
||||||
def sync_session_metadata(self, session_id: str) -> bool:
|
def sync_session_metadata(self, session_id: str) -> bool:
|
||||||
"""Refresh non-message session fields from the DB into the cached object."""
|
"""Refresh non-message session fields from the DB into the cached object.
|
||||||
|
|
||||||
|
``message_count`` is reconciled against the real ``chat_messages`` rows
|
||||||
|
rather than copied from the denormalized ``sessions.message_count``
|
||||||
|
column. That column drifts in normal operation — ``_persist_message``
|
||||||
|
swallows a failed insert but ``add_message`` has already appended in
|
||||||
|
memory, so the next successful persist writes rows+1, and a persist for
|
||||||
|
an uncached session writes 0. Hydration keys off this number: a
|
||||||
|
drifted-high column would reload the whole transcript on every warm
|
||||||
|
read, and a drifted-low one would leave the model a truncated one.
|
||||||
|
"""
|
||||||
session = self.sessions.get(session_id)
|
session = self.sessions.get(session_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
return False
|
return False
|
||||||
@@ -444,7 +469,11 @@ class SessionManager:
|
|||||||
session.archived = db_session.archived
|
session.archived = db_session.archived
|
||||||
session.owner = getattr(db_session, "owner", None)
|
session.owner = getattr(db_session, "owner", None)
|
||||||
session.is_important = getattr(db_session, "is_important", False) or False
|
session.is_important = getattr(db_session, "is_important", False) or False
|
||||||
session.message_count = getattr(db_session, "message_count", session.message_count) or 0
|
session.message_count = (
|
||||||
|
db.query(DbChatMessage)
|
||||||
|
.filter(DbChatMessage.session_id == session_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
||||||
|
|||||||
+80
-21
@@ -2861,13 +2861,22 @@ def setup_email_routes():
|
|||||||
return indexed_response
|
return indexed_response
|
||||||
return {"emails": [], "total": 0, "error": "Mail operation failed"}
|
return {"emails": [], "total": 0, "error": "Mail operation failed"}
|
||||||
|
|
||||||
def _read_email_sync(uid, folder, account_id, owner, mark_seen=True, full=False):
|
def _read_email_sync(uid, folder, account_id, owner, mark_seen=False, full=False):
|
||||||
"""Sync IMAP read — wrapped in to_thread by the async handler.
|
"""Sync IMAP read — wrapped in to_thread by the async handler.
|
||||||
|
|
||||||
The normal reader path fetches the headers plus a bounded body prefix.
|
The normal reader path fetches the headers plus a bounded body prefix.
|
||||||
That avoids downloading multi-megabyte attachments just to open a
|
That avoids downloading multi-megabyte attachments just to open a
|
||||||
message. Full-message fetch remains available for flows that need
|
message. Full-message fetch remains available for flows that need
|
||||||
attachment metadata immediately, such as forwarding.
|
attachment metadata immediately, such as forwarding.
|
||||||
|
|
||||||
|
`mark_seen` defaults to False because it mutates provider state: it
|
||||||
|
selects the mailbox read-write and issues a STORE. Only a foreground
|
||||||
|
open should ask for it, and it has to ask explicitly.
|
||||||
|
|
||||||
|
A failed \\Seen transition is reported as `mark_seen_failed` on an
|
||||||
|
otherwise normal response, never as an error. The body has already been
|
||||||
|
fetched at that point, so refusing to return it would turn a cosmetic
|
||||||
|
flag failure into an unreadable message.
|
||||||
"""
|
"""
|
||||||
import time as _t
|
import time as _t
|
||||||
_t0 = _t.monotonic()
|
_t0 = _t.monotonic()
|
||||||
@@ -2875,9 +2884,28 @@ def setup_email_routes():
|
|||||||
preview_bytes = 384 * 1024
|
preview_bytes = 384 * 1024
|
||||||
_t_select = 0.0
|
_t_select = 0.0
|
||||||
_t_fetch = 0.0
|
_t_fetch = 0.0
|
||||||
|
mark_seen_failed = False
|
||||||
try:
|
try:
|
||||||
with _imap(account_id, owner=owner) as conn:
|
with _imap(account_id, owner=owner) as conn:
|
||||||
|
# A foreground open owns both the body fetch and the \Seen
|
||||||
|
# transition. Keep them on one read-write IMAP selection so the
|
||||||
|
# route never schedules a second connection that can race the
|
||||||
|
# response. Prefetch/read-only callers retain BODY.PEEK and a
|
||||||
|
# read-only mailbox selection.
|
||||||
|
try:
|
||||||
|
conn.select(_q(folder), readonly=not mark_seen)
|
||||||
|
except Exception as select_exc:
|
||||||
|
if not mark_seen:
|
||||||
|
raise
|
||||||
|
# Read-only mailboxes (shared archives, some provider
|
||||||
|
# folders) reject a read-write SELECT. Serve the message
|
||||||
|
# read-only and report the flag failure.
|
||||||
|
logger.warning(
|
||||||
|
f"read-write SELECT rejected for {folder!r}; "
|
||||||
|
f"serving read-only without \\Seen: {select_exc}"
|
||||||
|
)
|
||||||
conn.select(_q(folder), readonly=True)
|
conn.select(_q(folder), readonly=True)
|
||||||
|
mark_seen_failed = True
|
||||||
_t_select = _t.monotonic() - _t0
|
_t_select = _t.monotonic() - _t0
|
||||||
fetch_query = "(BODY.PEEK[])" if full else f"(BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0.{preview_bytes}>)"
|
fetch_query = "(BODY.PEEK[])" if full else f"(BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0.{preview_bytes}>)"
|
||||||
status, msg_data = _imap_uid_fetch(conn, uid, fetch_query)
|
status, msg_data = _imap_uid_fetch(conn, uid, fetch_query)
|
||||||
@@ -2903,6 +2931,10 @@ def setup_email_routes():
|
|||||||
header_part = msg_data[0][1] or b""
|
header_part = msg_data[0][1] or b""
|
||||||
raw = header_part + b"\r\n" + text_part
|
raw = header_part + b"\r\n" + text_part
|
||||||
|
|
||||||
|
# Parse the fetched payload before mutating provider state. If
|
||||||
|
# the message is malformed enough that the reader cannot build
|
||||||
|
# a response, the caller gets an error while the message stays
|
||||||
|
# unread instead of receiving a false optimistic rollback.
|
||||||
msg = email_mod.message_from_bytes(raw)
|
msg = email_mod.message_from_bytes(raw)
|
||||||
|
|
||||||
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
||||||
@@ -2919,6 +2951,24 @@ def setup_email_routes():
|
|||||||
sender_name, sender_addr = email.utils.parseaddr(sender)
|
sender_name, sender_addr = email.utils.parseaddr(sender)
|
||||||
parsed_date = email.utils.parsedate_to_datetime(date_str) if date_str else None
|
parsed_date = email.utils.parsedate_to_datetime(date_str) if date_str else None
|
||||||
attachments = _list_attachments_from_msg(msg) if full else (_email_attachment_meta_cache_get(owner, account_id, folder, uid) or [])
|
attachments = _list_attachments_from_msg(msg) if full else (_email_attachment_meta_cache_get(owner, account_id, folder, uid) or [])
|
||||||
|
|
||||||
|
if mark_seen and not mark_seen_failed:
|
||||||
|
seen_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||||
|
if seen_status != "OK":
|
||||||
|
# Report, don't raise. The parsed body below is still a
|
||||||
|
# valid response; only the flag claim is untrue.
|
||||||
|
logger.warning(
|
||||||
|
f"IMAP STORE \\Seen failed for UID {uid} in {folder!r}: {seen_status}"
|
||||||
|
)
|
||||||
|
mark_seen_failed = True
|
||||||
|
|
||||||
|
# Only record the local flag transition when the provider actually
|
||||||
|
# accepted it, so the index and list cache cannot drift ahead of
|
||||||
|
# the mailbox.
|
||||||
|
if mark_seen and not mark_seen_failed:
|
||||||
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
|
_update_list_cache_seen(account_id, folder, uid, True)
|
||||||
|
|
||||||
related_attachments = []
|
related_attachments = []
|
||||||
if full and not _has_visible_attachments(msg):
|
if full and not _has_visible_attachments(msg):
|
||||||
related_attachments = _related_thread_attachments_sync(
|
related_attachments = _related_thread_attachments_sync(
|
||||||
@@ -3039,20 +3089,29 @@ def setup_email_routes():
|
|||||||
"boundaries": cached_boundaries,
|
"boundaries": cached_boundaries,
|
||||||
"thread_turns": cached_turns,
|
"thread_turns": cached_turns,
|
||||||
"sender_signature": cached_sender_sig,
|
"sender_signature": cached_sender_sig,
|
||||||
|
# Per-request, not part of the message: the route strips this
|
||||||
|
# before caching so a one-off flag failure is never replayed to
|
||||||
|
# later readers.
|
||||||
|
"mark_seen_failed": mark_seen_failed,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to read email {uid}: {e}")
|
logger.error(f"Failed to read email {uid}: {e}")
|
||||||
return {"error": "Mail operation failed"}
|
return {"error": "Mail operation failed"}
|
||||||
|
|
||||||
def _mark_email_seen_sync(uid, folder, account_id, owner):
|
def _mark_email_seen_sync(uid, folder, account_id, owner):
|
||||||
|
"""Synchronously mark a cached email seen and report success."""
|
||||||
try:
|
try:
|
||||||
with _imap(account_id, owner=owner) as conn:
|
with _imap(account_id, owner=owner) as conn:
|
||||||
conn.select(_q(folder))
|
conn.select(_q(folder), readonly=False)
|
||||||
conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Seen")
|
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||||
|
if status != "OK":
|
||||||
|
return False
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_update_list_cache_seen(account_id, folder, uid, True)
|
_update_list_cache_seen(account_id, folder, uid, True)
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"mark-seen after cached read failed uid={uid}: {e}")
|
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
@router.get("/read/{uid}")
|
@router.get("/read/{uid}")
|
||||||
async def read_email_by_uid(
|
async def read_email_by_uid(
|
||||||
@@ -3078,32 +3137,32 @@ def setup_email_routes():
|
|||||||
if cached.get("attachment_version") != EMAIL_READ_ATTACHMENT_VERSION:
|
if cached.get("attachment_version") != EMAIL_READ_ATTACHMENT_VERSION:
|
||||||
cached = None
|
cached = None
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
if mark_seen:
|
# A cache hit already holds a complete, valid message. Await the
|
||||||
try:
|
# STORE so the response reports the real flag state, but never let
|
||||||
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
|
# a failed STORE withhold a body we are holding in memory.
|
||||||
except RuntimeError:
|
if mark_seen and not await _asyncio.to_thread(
|
||||||
pass
|
_mark_email_seen_sync, uid, folder, account_id, owner
|
||||||
|
):
|
||||||
|
return {**cached, "mark_seen_failed": True}
|
||||||
return cached
|
return cached
|
||||||
if not full:
|
if not full:
|
||||||
persisted = _email_preview_cache_get(owner, account_id, folder, uid)
|
persisted = _email_preview_cache_get(owner, account_id, folder, uid)
|
||||||
if persisted and persisted.get("attachment_version") == EMAIL_READ_ATTACHMENT_VERSION:
|
if persisted and persisted.get("attachment_version") == EMAIL_READ_ATTACHMENT_VERSION:
|
||||||
_read_cache_put(ck, persisted)
|
_read_cache_put(ck, persisted)
|
||||||
if mark_seen:
|
if mark_seen and not await _asyncio.to_thread(
|
||||||
try:
|
_mark_email_seen_sync, uid, folder, account_id, owner
|
||||||
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
|
):
|
||||||
except RuntimeError:
|
return {**persisted, "mark_seen_failed": True}
|
||||||
pass
|
|
||||||
return persisted
|
return persisted
|
||||||
result = await _asyncio.to_thread(_read_email_sync, uid, folder, account_id, owner, mark_seen, full)
|
result = await _asyncio.to_thread(_read_email_sync, uid, folder, account_id, owner, mark_seen, full)
|
||||||
if result and not result.get("error"):
|
if result and not result.get("error"):
|
||||||
_read_cache_put(ck, result)
|
# `mark_seen_failed` describes this request, not the message, so it
|
||||||
|
# must not enter either cache — a later reader would otherwise be
|
||||||
|
# told a STORE failed that it never issued.
|
||||||
|
cacheable = {k: v for k, v in result.items() if k != "mark_seen_failed"}
|
||||||
|
_read_cache_put(ck, cacheable)
|
||||||
if not full:
|
if not full:
|
||||||
_email_preview_cache_put(owner, account_id, folder, uid, result)
|
_email_preview_cache_put(owner, account_id, folder, uid, cacheable)
|
||||||
if mark_seen:
|
|
||||||
try:
|
|
||||||
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _schedule_recent_email_warm(emails: list, folder: str, account_id: str | None, owner: str):
|
def _schedule_recent_email_warm(emails: list, folder: str, account_id: str | None, owner: str):
|
||||||
|
|||||||
@@ -137,44 +137,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
entry["metadata"] = meta
|
entry["metadata"] = meta
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def _db_message_metadata(m: DbChatMessage) -> Dict[str, Any]:
|
|
||||||
meta = {}
|
|
||||||
if m.meta_data:
|
|
||||||
try:
|
|
||||||
meta = json.loads(m.meta_data) or {}
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
meta = {}
|
|
||||||
if m.timestamp and "timestamp" not in meta:
|
|
||||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
|
||||||
return meta
|
|
||||||
|
|
||||||
def _hydrate_session_history_from_db(session_id: str, rows: list[DbChatMessage]) -> None:
|
|
||||||
"""Rebuild in-memory context from raw DB rows after a history load.
|
|
||||||
|
|
||||||
The browser history endpoint can return paged/display-trimmed messages,
|
|
||||||
but the next model call reads ``session.history``. After a restart or a
|
|
||||||
stale in-memory session, selecting an old chat through the paged endpoint
|
|
||||||
used to show the transcript while the model only saw fresh context.
|
|
||||||
"""
|
|
||||||
if not rows:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
session = session_manager.get_session(session_id)
|
|
||||||
except KeyError:
|
|
||||||
return
|
|
||||||
session.history = [
|
|
||||||
ChatMessage(role=m.role, content=m.content, metadata=_db_message_metadata(m) or None)
|
|
||||||
for m in rows
|
|
||||||
]
|
|
||||||
session.message_count = len(session.history)
|
|
||||||
|
|
||||||
def _session_needs_db_history_hydration(session_id: str, total: int) -> bool:
|
|
||||||
try:
|
|
||||||
session = session_manager.get_session(session_id)
|
|
||||||
except KeyError:
|
|
||||||
return False
|
|
||||||
return len(session.history or []) < int(total or 0)
|
|
||||||
|
|
||||||
@router.get("/api/history/{session_id}")
|
@router.get("/api/history/{session_id}")
|
||||||
async def get_session_history(
|
async def get_session_history(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -198,6 +160,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
)
|
)
|
||||||
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
|
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
|
||||||
page_offset = max(0, min(page_offset, total))
|
page_offset = max(0, min(page_offset, total))
|
||||||
|
# Keep display pagination page-scoped. ``get_session`` is the
|
||||||
|
# full model-context hydration seam and must not be entered here.
|
||||||
rows = (
|
rows = (
|
||||||
db.query(DbChatMessage)
|
db.query(DbChatMessage)
|
||||||
.filter(DbChatMessage.session_id == session_id)
|
.filter(DbChatMessage.session_id == session_id)
|
||||||
@@ -206,14 +170,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
.limit(page_limit)
|
.limit(page_limit)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
if _session_needs_db_history_hydration(session_id, total):
|
|
||||||
full_rows = (
|
|
||||||
db.query(DbChatMessage)
|
|
||||||
.filter(DbChatMessage.session_id == session_id)
|
|
||||||
.order_by(DbChatMessage.timestamp)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
_hydrate_session_history_from_db(session_id, full_rows)
|
|
||||||
history_dict = [
|
history_dict = [
|
||||||
entry for entry in (_db_history_entry(m) for m in rows)
|
entry for entry in (_db_history_entry(m) for m in rows)
|
||||||
if not (entry.get("metadata") or {}).get("hidden")
|
if not (entry.get("metadata") or {}).get("hidden")
|
||||||
@@ -258,7 +214,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
entry["metadata"] = msg["metadata"]
|
entry["metadata"] = msg["metadata"]
|
||||||
history_dict.append(entry)
|
history_dict.append(entry)
|
||||||
|
|
||||||
# Fallback: load from DB if in-memory is empty
|
# Fallback: load from DB if in-memory renders empty. Display only —
|
||||||
|
# get_session above is the hydration seam, so nothing here writes back
|
||||||
|
# into session.history — rebuilding it from raw rows would overwrite
|
||||||
|
# parsed multimodal content and the _db_id edit/delete keys it just set.
|
||||||
if not history_dict:
|
if not history_dict:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -268,17 +227,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
.order_by(DbChatMessage.timestamp)
|
.order_by(DbChatMessage.timestamp)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
db_history = []
|
|
||||||
for m in db_messages:
|
|
||||||
db_history.append(_db_history_entry(m))
|
|
||||||
if db_history:
|
|
||||||
# Rebuild in-memory history from the full set so hidden
|
|
||||||
# messages (e.g. compaction summaries) are kept for AI context.
|
|
||||||
_hydrate_session_history_from_db(session_id, db_messages)
|
|
||||||
# Response excludes hidden messages, matching the in-memory path.
|
# Response excludes hidden messages, matching the in-memory path.
|
||||||
history_dict = [
|
history_dict = [
|
||||||
m for m in db_history
|
entry for entry in (_db_history_entry(m) for m in db_messages)
|
||||||
if not (m.get("metadata") or {}).get("hidden")
|
if not (entry.get("metadata") or {}).get("hidden")
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"DB fallback failed for {session_id}: {e}")
|
logger.error(f"DB fallback failed for {session_id}: {e}")
|
||||||
@@ -645,8 +597,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
body = await request.json()
|
body = await request.json()
|
||||||
keep_count = body.get("keep_count", 0)
|
keep_count = body.get("keep_count", 0)
|
||||||
|
|
||||||
# Get the source session
|
# Get the source session. keep_count indexes into source.history,
|
||||||
source = session_manager.sessions.get(session_id)
|
# so this must go through get_session — reading the cache directly
|
||||||
|
# forks an empty transcript out of a metadata-only session after a
|
||||||
|
# restart (display pagination no longer hydrates it).
|
||||||
|
try:
|
||||||
|
source = session_manager.get_session(session_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(404, "Session not found")
|
raise HTTPException(404, "Session not found")
|
||||||
|
|
||||||
|
|||||||
@@ -801,15 +801,6 @@ def setup_session_routes(
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@router.get("/history/{sid}")
|
|
||||||
def get_history(request: Request, sid: str):
|
|
||||||
_verify_session_owner(request, sid)
|
|
||||||
try:
|
|
||||||
session = session_manager.get_session(sid)
|
|
||||||
except KeyError:
|
|
||||||
raise HTTPException(404, f"Session {sid} not found")
|
|
||||||
return {"history": [msg.to_dict() for msg in session.history]}
|
|
||||||
|
|
||||||
@router.get("/session/{sid}/export")
|
@router.get("/session/{sid}/export")
|
||||||
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
|
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
|
||||||
"""Export conversation history as a downloadable file.
|
"""Export conversation history as a downloadable file.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import collections
|
import collections
|
||||||
from typing import Optional, Callable, Awaitable, Tuple, Dict
|
from typing import Optional, Callable, Awaitable, Tuple, Dict
|
||||||
|
from core.platform_compat import IS_WINDOWS, find_bash
|
||||||
from src.constants import MAX_OUTPUT_CHARS
|
from src.constants import MAX_OUTPUT_CHARS
|
||||||
|
|
||||||
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
|
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
|
||||||
@@ -16,6 +17,27 @@ PROGRESS_TAIL_LINES = 12
|
|||||||
TMUX_CAPTURE_LINES = 2000
|
TMUX_CAPTURE_LINES = 2000
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_bash_subprocess(command: str, **kwargs):
|
||||||
|
"""Start the agent shell with Bash semantics on every supported OS.
|
||||||
|
|
||||||
|
``asyncio.create_subprocess_shell`` delegates to ``cmd.exe`` on native
|
||||||
|
Windows. That contradicts the Bash tool contract and makes POSIX commands
|
||||||
|
such as ``pwd``, ``ls -la``, and ``cat`` unreliable even when the launcher
|
||||||
|
has found Git Bash. Pass the selected workspace as a structural ``cwd``
|
||||||
|
argument; Git Bash inherits that native Windows directory and exposes it
|
||||||
|
using its normal ``/c/...`` representation.
|
||||||
|
"""
|
||||||
|
if IS_WINDOWS:
|
||||||
|
bash = find_bash()
|
||||||
|
if not bash:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Git Bash is required for the Bash tool on Windows; "
|
||||||
|
"install Git for Windows and restart Odysseus"
|
||||||
|
)
|
||||||
|
return await asyncio.create_subprocess_exec(bash, "-c", command, **kwargs)
|
||||||
|
return await asyncio.create_subprocess_shell(command, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _tmux_session_name(session_id: Optional[str]) -> str:
|
def _tmux_session_name(session_id: Optional[str]) -> str:
|
||||||
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
|
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
|
||||||
return f"ody-agent-{raw[:80] or 'default'}"
|
return f"ody-agent-{raw[:80] or 'default'}"
|
||||||
@@ -280,7 +302,10 @@ class BashTool:
|
|||||||
progress_cb = ctx.get("progress_cb")
|
progress_cb = ctx.get("progress_cb")
|
||||||
_subproc_env = ctx.get("subproc_env")
|
_subproc_env = ctx.get("subproc_env")
|
||||||
session_id = ctx.get("session_id")
|
session_id = ctx.get("session_id")
|
||||||
if session_id and shutil.which("tmux"):
|
# tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on
|
||||||
|
# native Windows must not bypass the Git Bash launcher below: the tmux
|
||||||
|
# setup hard-codes /bin/bash and cannot safely consume a native cwd.
|
||||||
|
if session_id and not IS_WINDOWS and shutil.which("tmux"):
|
||||||
stdout, stderr, rc, timed_out = await _run_tmux_bash(
|
stdout, stderr, rc, timed_out = await _run_tmux_bash(
|
||||||
content,
|
content,
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
@@ -307,13 +332,16 @@ class BashTool:
|
|||||||
"tmux_session": _tmux_session_name(str(session_id)),
|
"tmux_session": _tmux_session_name(str(session_id)),
|
||||||
}
|
}
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_shell(
|
try:
|
||||||
|
proc = await _create_bash_subprocess(
|
||||||
content,
|
content,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
env=_subproc_env,
|
env=_subproc_env,
|
||||||
cwd=agent_cwd(),
|
cwd=agent_cwd(),
|
||||||
)
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
return {"error": f"bash: {e}", "exit_code": 1}
|
||||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||||
proc,
|
proc,
|
||||||
timeout=DEFAULT_BASH_TIMEOUT,
|
timeout=DEFAULT_BASH_TIMEOUT,
|
||||||
|
|||||||
+28
-19
@@ -15,6 +15,12 @@ import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
|||||||
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
||||||
import searchChatModule from './js/search-chat.js';
|
import searchChatModule from './js/search-chat.js';
|
||||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||||
|
import {
|
||||||
|
revealApplicationShellAfterPaint,
|
||||||
|
runDeferredRouteOpener,
|
||||||
|
deferRouteOpener,
|
||||||
|
settleSessionHydration
|
||||||
|
} from './js/startupShell.js';
|
||||||
import markdownModule from './js/markdown.js';
|
import markdownModule from './js/markdown.js';
|
||||||
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
||||||
import sessionModule from './js/sessions.js';
|
import sessionModule from './js/sessions.js';
|
||||||
@@ -1217,12 +1223,13 @@ function initializeEventListeners() {
|
|||||||
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
|
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
|
||||||
};
|
};
|
||||||
const _opener = _routeOpen[urlPath];
|
const _opener = _routeOpen[urlPath];
|
||||||
// Defer the opener — at this point in init, the modules whose handlers
|
// Defer the opener — at this point in init, the modules whose handlers we
|
||||||
// we trigger (#rail-new-session click handler, the email-section header
|
// trigger (#rail-new-session click handler, the email-section header click
|
||||||
// click handler in emailInbox, sessionModule's loaded session list) are
|
// handler in emailInbox, sessionModule) are still being wired up further
|
||||||
// still being wired up further down in this same function. Stash the
|
// down in this same function. startupShell decides when it can run: as soon
|
||||||
// opener so it runs from sessionModule.loadSessions().finally() below.
|
// as wiring completes, or — for the routes that read the session list —
|
||||||
if (_opener) window._odysseusRouteOpener = _opener;
|
// once /api/sessions has settled.
|
||||||
|
deferRouteOpener(urlPath, _opener);
|
||||||
|
|
||||||
// Archive browser tool button
|
// Archive browser tool button
|
||||||
const toolLibraryBtn = el('tool-library-btn');
|
const toolLibraryBtn = el('tool-library-btn');
|
||||||
@@ -4315,6 +4322,10 @@ function startOdysseusApp() {
|
|||||||
// Load initial data
|
// Load initial data
|
||||||
presetsModule.loadPresets(uiModule.showError);
|
presetsModule.loadPresets(uiModule.showError);
|
||||||
|
|
||||||
|
// Core wiring is complete for this turn — reveal the shell independently of
|
||||||
|
// the session-list request.
|
||||||
|
revealApplicationShellAfterPaint();
|
||||||
|
|
||||||
if (sessionModule) {
|
if (sessionModule) {
|
||||||
sessionModule.initDependencies({
|
sessionModule.initDependencies({
|
||||||
API_BASE: API_BASE,
|
API_BASE: API_BASE,
|
||||||
@@ -4326,21 +4337,19 @@ function startOdysseusApp() {
|
|||||||
scrollHistory: uiModule.scrollHistoryInstant
|
scrollHistory: uiModule.scrollHistoryInstant
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load sessions first (critical path) — remove loader when done
|
// sessionModule is now wired, so every route opener has the modules it
|
||||||
sessionModule.loadSessions()
|
// drives. The ones that read no session data open here rather than
|
||||||
.catch(e => console.warn('loadSessions error:', e))
|
// queueing behind /api/sessions.
|
||||||
.finally(() => {
|
runDeferredRouteOpener();
|
||||||
const loader = document.getElementById('app-loader');
|
|
||||||
if (loader) { loader.style.opacity = '0'; setTimeout(() => loader.remove(), 300); }
|
// The shell is already usable at this point; session hydration is
|
||||||
// Fire any URL route opener now that sessions + module wiring are
|
// sidebar-local and settles on its own schedule.
|
||||||
// ready. Deferred from up top of init for exactly this reason.
|
settleSessionHydration(() => sessionModule.loadSessions());
|
||||||
if (window._odysseusRouteOpener) {
|
|
||||||
try { window._odysseusRouteOpener(); } catch (_) {}
|
|
||||||
window._odysseusRouteOpener = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Session module not loaded!');
|
console.error('Session module not loaded!');
|
||||||
|
// Nothing will hydrate. Settle immediately so the sidebar exposes the
|
||||||
|
// failure; session-dependent routes must remain unopened without data.
|
||||||
|
settleSessionHydration(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const runNonCriticalStartup = (fn, delay = 4000) => {
|
const runNonCriticalStartup = (fn, delay = 4000) => {
|
||||||
|
|||||||
+17
-5
@@ -248,8 +248,8 @@
|
|||||||
}, { once: true });
|
}, { once: true });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
|
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
|
||||||
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
|
<link rel="modulepreload" href="/static/app.js?v=20260808startupshell1">
|
||||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
|
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
|
||||||
<link rel="modulepreload" href="/static/js/ui.js">
|
<link rel="modulepreload" href="/static/js/ui.js">
|
||||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||||
@@ -286,7 +286,13 @@
|
|||||||
if(!document.getElementById('app-loader')){clearInterval(iv);return}
|
if(!document.getElementById('app-loader')){clearInterval(iv);return}
|
||||||
render();
|
render();
|
||||||
},150);
|
},150);
|
||||||
setTimeout(function(){var l=document.getElementById('app-loader');if(l){l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
|
// startupShell.js hides the loader as soon as the shell is wired; it calls
|
||||||
|
// back here to stop the wave because this interval is owned by this script.
|
||||||
|
window.__odysseusLoaderWaveStop=function(){clearInterval(iv)};
|
||||||
|
// Last-resort fallback for a boot that never reaches app.js at all. Must
|
||||||
|
// still REMOVE the node: sessions.js reads its presence as "startup in
|
||||||
|
// progress" and stops clearing the composer while it is around.
|
||||||
|
setTimeout(function(){var l=document.getElementById('app-loader');if(l){clearInterval(iv);l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<!-- Memory Management Modal -->
|
<!-- Memory Management Modal -->
|
||||||
@@ -813,7 +819,13 @@
|
|||||||
<button class="session-bulk-btn" id="session-bulk-cancel" title="Cancel"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
<button class="session-bulk-btn" id="session-bulk-cancel" title="Cancel"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="session-list" role="listbox"></div>
|
<div id="session-list" role="listbox">
|
||||||
|
<!-- Sidebar-local bootstrap state. renderSessionList() replaces the
|
||||||
|
whole list on first hydration, so this row is transient. -->
|
||||||
|
<div id="session-list-loading" class="list-item session-list-bootstrap" role="option" aria-disabled="true" aria-live="polite" aria-atomic="true">
|
||||||
|
<span class="grow muted" data-session-list-status>Loading chats…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Hidden dropdown for session actions -->
|
<!-- Hidden dropdown for session actions -->
|
||||||
<div id="session-actions-dropdown" class="dropdown hidden">
|
<div id="session-actions-dropdown" class="dropdown hidden">
|
||||||
@@ -2531,7 +2543,7 @@
|
|||||||
<script type="module" src="/static/js/censor.js"></script>
|
<script type="module" src="/static/js/censor.js"></script>
|
||||||
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script>
|
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script>
|
||||||
<script type="module" src="/static/js/assistant.js"></script>
|
<script type="module" src="/static/js/assistant.js"></script>
|
||||||
<script type="module" src="/static/app.js?v=20260723tasksbulkfeedback1"></script> <!-- app.js must be LAST -->
|
<script type="module" src="/static/app.js?v=20260808startupshell1"></script> <!-- app.js must be LAST -->
|
||||||
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
||||||
<script type="module" src="/static/js/a11y.js"></script>
|
<script type="module" src="/static/js/a11y.js"></script>
|
||||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
|
|||||||
| **`chatRenderer.js`** | Message DOM construction: `addMessage`, role labels, model route labels, color coding, footers, metrics, code blocks, sources boxes (`web`/`research`/`RAG`), findings box, images, report links, ask-user cards, welcome screen, and transcript utilities. |
|
| **`chatRenderer.js`** | Message DOM construction: `addMessage`, role labels, model route labels, color coding, footers, metrics, code blocks, sources boxes (`web`/`research`/`RAG`), findings box, images, report links, ask-user cards, welcome screen, and transcript utilities. |
|
||||||
| **`streamingRenderer.js`** | Incremental streaming renderer used by `chat.js`. Freezes finalized DOM blocks and only re-renders the growing tail to avoid flicker and O(N²) re-parsing. |
|
| **`streamingRenderer.js`** | Incremental streaming renderer used by `chat.js`. Freezes finalized DOM blocks and only re-renders the growing tail to avoid flicker and O(N²) re-parsing. |
|
||||||
| **`streamingSegmenter.js`** | Splits a token stream into display units (text vs code fences) for `streamingRenderer.js`. |
|
| **`streamingSegmenter.js`** | Splits a token stream into display units (text vs code fences) for `streamingRenderer.js`. |
|
||||||
|
| **`liveThinkingThrottle.js`** | Trailing-edge coalescer for the live thinking block in `chat.js`: one DOM commit per 100 ms carrying the latest reasoning text, with `flush`/`cancel` for terminal and session-switch paths. |
|
||||||
| **`slashCommands.js`** | Slash-command registry (`/help`, `/setup`, etc.), parsing, and dispatch handlers. Exported functions are consumed by `chat.js` and `slashAutocomplete.js`. |
|
| **`slashCommands.js`** | Slash-command registry (`/help`, `/setup`, etc.), parsing, and dispatch handlers. Exported functions are consumed by `chat.js` and `slashAutocomplete.js`. |
|
||||||
| **`slashAutocomplete.js`** | Composer autocomplete popup for `/` commands. |
|
| **`slashAutocomplete.js`** | Composer autocomplete popup for `/` commands. |
|
||||||
| **`composerArrowUpRecall.js`** | Recall last user message with `↑` on an empty composer. |
|
| **`composerArrowUpRecall.js`** | Recall last user message with `↑` on an empty composer. |
|
||||||
|
|||||||
+496
-213
File diff suppressed because it is too large
Load Diff
+49
-10
@@ -149,6 +149,7 @@ let _loading = false;
|
|||||||
let _expanded = false;
|
let _expanded = false;
|
||||||
let _docModule = null;
|
let _docModule = null;
|
||||||
let _listSpinner = null;
|
let _listSpinner = null;
|
||||||
|
let _openEmailRequestSeq = 0;
|
||||||
let _senderFilter = null; // email address (lowercased) to filter by, or null
|
let _senderFilter = null; // email address (lowercased) to filter by, or null
|
||||||
let _senderFilterLabel = null; // display label for the active filter chip
|
let _senderFilterLabel = null; // display label for the active filter chip
|
||||||
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
|
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
|
||||||
@@ -187,7 +188,7 @@ export function init(documentModule) {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
if (opts.compose) { _composeNew(); return; }
|
if (opts.compose) { _composeNew(); return; }
|
||||||
if (opts.email) {
|
if (opts.email) {
|
||||||
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '');
|
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '', '', opts.mailboxContext || null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -751,7 +752,21 @@ function _createEmailItem(em) {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
|
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '', mailboxContext = null) {
|
||||||
|
const openRequestSeq = ++_openEmailRequestSeq;
|
||||||
|
const folderAtStart = mailboxContext?.messageFolder || _currentFolder;
|
||||||
|
const accountAtStart = mailboxContext?.accountId ?? (window.__odysseusActiveEmailAccount || '');
|
||||||
|
const accountQueryAtStart = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||||
|
const mailboxContextIsCurrent = typeof mailboxContext?.isCurrent === 'function'
|
||||||
|
? mailboxContext.isCurrent
|
||||||
|
: () => (
|
||||||
|
folderAtStart === _currentFolder &&
|
||||||
|
accountAtStart === (window.__odysseusActiveEmailAccount || '')
|
||||||
|
);
|
||||||
|
const isCurrentOpen = () => (
|
||||||
|
openRequestSeq === _openEmailRequestSeq &&
|
||||||
|
mailboxContextIsCurrent()
|
||||||
|
);
|
||||||
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : '';
|
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : '';
|
||||||
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
||||||
// Body pre-fill from the agent's open_email_reply tool call takes the
|
// Body pre-fill from the agent's open_email_reply tool call takes the
|
||||||
@@ -780,9 +795,10 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
let data = preloadedData;
|
let data = preloadedData;
|
||||||
if (!data) {
|
if (!data) {
|
||||||
const fullQS = mode === 'forward' ? '&full=1' : '';
|
const fullQS = mode === 'forward' ? '&full=1' : '';
|
||||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}${fullQS}`);
|
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${accountQueryAtStart}&mark_seen=true${fullQS}`);
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
}
|
}
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
console.error('Failed to read email:', data.error);
|
console.error('Failed to read email:', data.error);
|
||||||
return;
|
return;
|
||||||
@@ -808,7 +824,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
message_id: _fallback(data.message_id, em.message_id),
|
message_id: _fallback(data.message_id, em.message_id),
|
||||||
};
|
};
|
||||||
if (wantsAiReply) {
|
if (wantsAiReply) {
|
||||||
const activeReplyAccount = data.account_id || em.account_id || window.__odysseusActiveEmailAccount || '';
|
const activeReplyAccount = data.account_id || em.account_id || accountAtStart;
|
||||||
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
|
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
|
||||||
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
|
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
|
||||||
} else {
|
} else {
|
||||||
@@ -834,7 +850,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
session_id: currentSessionId,
|
session_id: currentSessionId,
|
||||||
message_id: data.message_id || '',
|
message_id: data.message_id || '',
|
||||||
uid: String(em.uid || ''),
|
uid: String(em.uid || ''),
|
||||||
folder: _currentFolder,
|
folder: folderAtStart,
|
||||||
account_id: activeReplyAccount,
|
account_id: activeReplyAccount,
|
||||||
fast: true,
|
fast: true,
|
||||||
user_hint: (noteHint || '').trim() || undefined,
|
user_hint: (noteHint || '').trim() || undefined,
|
||||||
@@ -842,6 +858,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (draftToastTimer) clearTimeout(draftToastTimer);
|
if (draftToastTimer) clearTimeout(draftToastTimer);
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
if (result.success && result.reply) {
|
if (result.success && result.reply) {
|
||||||
aiSuggestedBody = _cleanAiReplyText(result.reply);
|
aiSuggestedBody = _cleanAiReplyText(result.reply);
|
||||||
} else {
|
} else {
|
||||||
@@ -855,6 +872,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (draftToastTimer) clearTimeout(draftToastTimer);
|
if (draftToastTimer) clearTimeout(draftToastTimer);
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
console.error('AI reply generation failed:', e);
|
console.error('AI reply generation failed:', e);
|
||||||
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
|
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -862,8 +880,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
em.is_read = true;
|
if (!isCurrentOpen()) return;
|
||||||
if (itemEl) itemEl.classList.remove('email-unread');
|
// Only claim the message is read when the provider accepted the \Seen
|
||||||
|
// transition. A failed STORE still opens the message; it just stays unread.
|
||||||
|
const markedSeen = !data.mark_seen_failed;
|
||||||
|
em.is_read = markedSeen;
|
||||||
|
if (itemEl) itemEl.classList.toggle('email-unread', !markedSeen);
|
||||||
|
|
||||||
// Addresses to exclude from Reply All. Prefer the full set of configured
|
// Addresses to exclude from Reply All. Prefer the full set of configured
|
||||||
// accounts (so a multi-account user's other mailboxes are excluded too),
|
// accounts (so a multi-account user's other mailboxes are excluded too),
|
||||||
@@ -911,7 +933,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
if (mode !== 'forward' && data.message_id) content += `\nIn-Reply-To: ${data.message_id}`;
|
if (mode !== 'forward' && data.message_id) content += `\nIn-Reply-To: ${data.message_id}`;
|
||||||
if (mode !== 'forward' && data.message_id) content += `\nReferences: ${data.references ? data.references + ' ' + data.message_id : data.message_id}`;
|
if (mode !== 'forward' && data.message_id) content += `\nReferences: ${data.references ? data.references + ' ' + data.message_id : data.message_id}`;
|
||||||
content += `\nX-Source-UID: ${em.uid}`;
|
content += `\nX-Source-UID: ${em.uid}`;
|
||||||
content += `\nX-Source-Folder: ${_currentFolder}`;
|
content += `\nX-Source-Folder: ${folderAtStart}`;
|
||||||
if (data.attachments && data.attachments.length > 0) {
|
if (data.attachments && data.attachments.length > 0) {
|
||||||
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
|
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
|
||||||
content += `\nX-Attachments: ${attStr}`;
|
content += `\nX-Attachments: ${attStr}`;
|
||||||
@@ -980,21 +1002,27 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
// and block Send on long threads.
|
// and block Send on long threads.
|
||||||
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
|
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
|
||||||
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
|
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
|
||||||
? _docModule.findEmailDocId(em.uid, _currentFolder)
|
? _docModule.findEmailDocId(em.uid, folderAtStart)
|
||||||
: null;
|
: null;
|
||||||
if (existingDocId) {
|
if (existingDocId) {
|
||||||
if (!_docModule.isPanelOpen()) _docModule.openPanel();
|
if (!_docModule.isPanelOpen()) _docModule.openPanel();
|
||||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
await _docModule.loadDocument(existingDocId);
|
await _docModule.loadDocument(existingDocId);
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
|
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
|
||||||
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
|
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
}
|
}
|
||||||
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
|
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
|
||||||
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
|
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
}
|
}
|
||||||
_bringEmailReplyDraftToFrontOnMobile();
|
_bringEmailReplyDraftToFrontOnMobile();
|
||||||
} else {
|
} else {
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
let activeSid = await _createEmailChat(data, { forceNew: true });
|
let activeSid = await _createEmailChat(data, { forceNew: true });
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
if (!activeSid) {
|
if (!activeSid) {
|
||||||
console.error('reply: could not obtain a session_id');
|
console.error('reply: could not obtain a session_id');
|
||||||
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
|
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
|
||||||
@@ -1012,13 +1040,20 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
let docRes = await createReplyDoc(activeSid);
|
let docRes = await createReplyDoc(activeSid);
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
if (docRes.status === 404) {
|
if (docRes.status === 404) {
|
||||||
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
|
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
activeSid = await _createEmailChat(data, { forceNew: true });
|
activeSid = await _createEmailChat(data, { forceNew: true });
|
||||||
if (activeSid) docRes = await createReplyDoc(activeSid);
|
if (!isCurrentOpen()) return;
|
||||||
|
if (activeSid) {
|
||||||
|
docRes = await createReplyDoc(activeSid);
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!docRes.ok) {
|
if (!docRes.ok) {
|
||||||
const errText = await docRes.text();
|
const errText = await docRes.text();
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
|
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
|
||||||
// uiModule isn't statically imported here — use the dynamic
|
// uiModule isn't statically imported here — use the dynamic
|
||||||
// import pattern the rest of this file uses. (Previously this
|
// import pattern the rest of this file uses. (Previously this
|
||||||
@@ -1028,10 +1063,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = await docRes.json();
|
const doc = await docRes.json();
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
if (doc.id) {
|
if (doc.id) {
|
||||||
const wasOpen = _docModule.isPanelOpen();
|
const wasOpen = _docModule.isPanelOpen();
|
||||||
if (!wasOpen) _docModule.openPanel();
|
if (!wasOpen) _docModule.openPanel();
|
||||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
// Use the doc dict from the POST directly — avoids a 404 race
|
// Use the doc dict from the POST directly — avoids a 404 race
|
||||||
// when the GET fires before the new row is visible to the read
|
// when the GET fires before the new row is visible to the read
|
||||||
// connection (or when caching is interfering). loadDocument's
|
// connection (or when caching is interfering). loadDocument's
|
||||||
@@ -1040,12 +1077,14 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
_docModule.injectFreshDoc(doc);
|
_docModule.injectFreshDoc(doc);
|
||||||
} else {
|
} else {
|
||||||
await _docModule.loadDocument(doc.id);
|
await _docModule.loadDocument(doc.id);
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
}
|
}
|
||||||
_bringEmailReplyDraftToFrontOnMobile();
|
_bringEmailReplyDraftToFrontOnMobile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
console.error('Failed to open email:', e);
|
console.error('Failed to open email:', e);
|
||||||
// Surface the failure so a silent throw in the reply flow doesn't
|
// Surface the failure so a silent throw in the reply flow doesn't
|
||||||
// look like "nothing happened". Dynamic import — uiModule isn't a
|
// look like "nothing happened". Dynamic import — uiModule isn't a
|
||||||
|
|||||||
+390
-123
@@ -30,6 +30,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _emailUnreadChipClickWired = false;
|
let _emailUnreadChipClickWired = false;
|
||||||
let _libLoadSeq = 0;
|
let _libLoadSeq = 0;
|
||||||
|
let _emailMailboxGeneration = 0;
|
||||||
|
let _emailCardOpenSeq = 0;
|
||||||
|
let _emailReadMutationSeq = 0;
|
||||||
|
const _emailReadMutations = new Map();
|
||||||
let _libFolderSeq = 0;
|
let _libFolderSeq = 0;
|
||||||
let _libSearchSeq = 0;
|
let _libSearchSeq = 0;
|
||||||
let _libSearchHadResults = false;
|
let _libSearchHadResults = false;
|
||||||
@@ -837,14 +841,41 @@ document.addEventListener('keydown', (e) => {
|
|||||||
e.stopImmediatePropagation?.();
|
e.stopImmediatePropagation?.();
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
function _syncEmailReadState(uid, isRead = true) {
|
function _emailReadContextKey(context) {
|
||||||
|
return [context.accountId, context.folder, context.uid].map(value => String(value || '')).join('\u0000');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _emailReadContextIsCurrent(context) {
|
||||||
|
if (!context) return true;
|
||||||
|
return (
|
||||||
|
String(state._libAccountId || '') === context.accountId &&
|
||||||
|
String(state._libFolder || 'INBOX') === context.libraryFolder &&
|
||||||
|
_emailMailboxGeneration === context.mailboxGeneration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _emailMatchesReadContext(email, context) {
|
||||||
|
if (String(email?.uid || '') !== context.uid) return false;
|
||||||
|
const accountId = String(email?.account_id || context.accountId);
|
||||||
|
const folder = String(email?.folder || context.folder);
|
||||||
|
return accountId === context.accountId && folder === context.folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncEmailReadState(uid, isRead = true, context = null) {
|
||||||
if (uid == null) return;
|
if (uid == null) return;
|
||||||
const uidStr = String(uid);
|
const uidStr = String(uid);
|
||||||
const read = !!isRead;
|
const read = !!isRead;
|
||||||
const match = (state._libEmails || []).find(x => String(x.uid) === uidStr);
|
if (context && (!_emailReadContextIsCurrent(context) || uidStr !== context.uid)) return;
|
||||||
|
const match = (state._libEmails || []).find(x => (
|
||||||
|
context ? _emailMatchesReadContext(x, context) : String(x.uid) === uidStr
|
||||||
|
));
|
||||||
if (match) match.is_read = read;
|
if (match) match.is_read = read;
|
||||||
|
|
||||||
document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => {
|
document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => {
|
||||||
|
if (context && (
|
||||||
|
String(card.dataset.emailAccount || '') !== context.accountId ||
|
||||||
|
String(card.dataset.emailFolder || '') !== context.folder
|
||||||
|
)) return;
|
||||||
card.classList.toggle('email-card-unread', !read);
|
card.classList.toggle('email-card-unread', !read);
|
||||||
const titleRow = card.querySelector('.email-card-titlerow');
|
const titleRow = card.querySelector('.email-card-titlerow');
|
||||||
if (read) {
|
if (read) {
|
||||||
@@ -1762,11 +1793,18 @@ function _rememberedEmailAccountId() {
|
|||||||
// results and __scheduled__ are deliberately not cached.
|
// results and __scheduled__ are deliberately not cached.
|
||||||
const _libListCache = new Map();
|
const _libListCache = new Map();
|
||||||
const _LIB_CACHE_MAX = 24;
|
const _LIB_CACHE_MAX = 24;
|
||||||
|
const _LIB_INITIAL_PAGE_SIZE = 100;
|
||||||
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
|
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
|
||||||
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
|
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
|
||||||
let _libPrewarmTimer = null;
|
const _LIB_PREWARM_COOLDOWN_MS = 5 * 60 * 1000;
|
||||||
|
let _libPrewarmDelayTimer = null;
|
||||||
|
let _libPrewarmIdleHandle = null;
|
||||||
let _libPrewarmPromise = null;
|
let _libPrewarmPromise = null;
|
||||||
|
let _libPrewarmResolve = null;
|
||||||
|
let _libPrewarmAbortController = null;
|
||||||
|
let _libPrewarmDetachPriorityListeners = null;
|
||||||
|
let _libPrewarmGeneration = 0;
|
||||||
let _libLastPrewarmAt = 0;
|
let _libLastPrewarmAt = 0;
|
||||||
let _libUnreadPrewarmKey = '';
|
let _libUnreadPrewarmKey = '';
|
||||||
let _libUnreadPrewarmAt = 0;
|
let _libUnreadPrewarmAt = 0;
|
||||||
@@ -1908,6 +1946,7 @@ function _resetEmailListForFreshLoad({ useCache = true } = {}) {
|
|||||||
_exitEmailReaderModeForList();
|
_exitEmailReaderModeForList();
|
||||||
_resetBulkSelectionForContextChange();
|
_resetBulkSelectionForContextChange();
|
||||||
state._libOffset = 0;
|
state._libOffset = 0;
|
||||||
|
_emailMailboxGeneration += 1;
|
||||||
_libLoadSeq += 1;
|
_libLoadSeq += 1;
|
||||||
const ck = _libCacheKey();
|
const ck = _libCacheKey();
|
||||||
const cached = useCache ? _libCacheGet(ck) : null;
|
const cached = useCache ? _libCacheGet(ck) : null;
|
||||||
@@ -2076,66 +2115,244 @@ function _isChatInteractionBusy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _loadEmailsWhenChatIdle({ delay = 50, retries = 180, options = {} } = {}) {
|
function _canRunEmailPrewarm() {
|
||||||
const run = () => {
|
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
|
||||||
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
|
if (document.visibilityState && document.visibilityState !== 'visible') return false;
|
||||||
if (_isChatInteractionBusy() && retries > 0) {
|
return !_isChatInteractionBusy();
|
||||||
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
|
}
|
||||||
|
|
||||||
|
function _isEmailPrewarmTemporarilyBlocked() {
|
||||||
|
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
|
||||||
|
if (document.visibilityState && document.visibilityState !== 'visible') return false;
|
||||||
|
return _isChatInteractionBusy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isEmailPrewarmCurrent(generation, signal) {
|
||||||
|
return generation === _libPrewarmGeneration
|
||||||
|
&& !signal?.aborted
|
||||||
|
&& _canRunEmailPrewarm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _settleEmailPrewarm(generation, value = false) {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
const resolve = _libPrewarmResolve;
|
||||||
|
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
|
||||||
|
_libPrewarmDelayTimer = null;
|
||||||
|
_libPrewarmIdleHandle = null;
|
||||||
|
_libPrewarmPromise = null;
|
||||||
|
_libPrewarmResolve = null;
|
||||||
|
_libPrewarmAbortController = null;
|
||||||
|
_libPrewarmDetachPriorityListeners = null;
|
||||||
|
detachPriorityListeners?.();
|
||||||
|
resolve?.(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cancelEmailPrewarm() {
|
||||||
|
const resolve = _libPrewarmResolve;
|
||||||
|
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
|
||||||
|
_libPrewarmGeneration += 1;
|
||||||
|
if (_libPrewarmDelayTimer !== null) {
|
||||||
|
clearTimeout(_libPrewarmDelayTimer);
|
||||||
|
}
|
||||||
|
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
|
||||||
|
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
|
||||||
|
}
|
||||||
|
try { _libPrewarmAbortController?.abort(); } catch (_) {}
|
||||||
|
_libPrewarmDelayTimer = null;
|
||||||
|
_libPrewarmIdleHandle = null;
|
||||||
|
_libPrewarmPromise = null;
|
||||||
|
_libPrewarmResolve = null;
|
||||||
|
_libPrewarmAbortController = null;
|
||||||
|
_libPrewarmDetachPriorityListeners = null;
|
||||||
|
detachPriorityListeners?.();
|
||||||
|
resolve?.(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scheduleEmailPrewarm(task, { delay = 0 } = {}) {
|
||||||
|
if (_libPrewarmPromise) return _libPrewarmPromise;
|
||||||
|
// Do not disguise a timer as idle work. Browsers without the genuine idle
|
||||||
|
// callback simply skip this optional optimization and load on demand.
|
||||||
|
if (typeof window.requestIdleCallback !== 'function') return Promise.resolve(false);
|
||||||
|
|
||||||
|
const generation = ++_libPrewarmGeneration;
|
||||||
|
_libPrewarmPromise = new Promise(resolve => { _libPrewarmResolve = resolve; });
|
||||||
|
const promise = _libPrewarmPromise;
|
||||||
|
let attemptPending = false;
|
||||||
|
let retryRequested = false;
|
||||||
|
|
||||||
|
function clearScheduledAttempt() {
|
||||||
|
if (_libPrewarmDelayTimer !== null) clearTimeout(_libPrewarmDelayTimer);
|
||||||
|
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
|
||||||
|
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
|
||||||
|
}
|
||||||
|
_libPrewarmDelayTimer = null;
|
||||||
|
_libPrewarmIdleHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleIdleRetry(delay = 500) {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
retryRequested = true;
|
||||||
|
if (attemptPending || _libPrewarmDelayTimer !== null || _libPrewarmIdleHandle !== null) return;
|
||||||
|
if (document.visibilityState && document.visibilityState !== 'visible') return;
|
||||||
|
_libPrewarmDelayTimer = setTimeout(requestIdle, Math.max(50, Number(delay) || 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePriorityChange() {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
if (_canRunEmailPrewarm()) {
|
||||||
|
scheduleIdleRetry(50);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_loadEmails(options);
|
|
||||||
|
const priorityBlocked = _isChatInteractionBusy()
|
||||||
|
|| (document.visibilityState && document.visibilityState !== 'visible');
|
||||||
|
if (!priorityBlocked) return;
|
||||||
|
|
||||||
|
retryRequested = true;
|
||||||
|
clearScheduledAttempt();
|
||||||
|
const controller = _libPrewarmAbortController;
|
||||||
|
_libPrewarmAbortController = null;
|
||||||
|
try { controller?.abort(); } catch (_) {}
|
||||||
|
// A hidden page waits for visibilitychange. Chat priority also retains the
|
||||||
|
// timer fallback for busy-until windows whose final transition has no event.
|
||||||
|
if (!document.visibilityState || document.visibilityState === 'visible') {
|
||||||
|
scheduleIdleRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('odysseus:chat-busy-change', handlePriorityChange);
|
||||||
|
document.addEventListener('visibilitychange', handlePriorityChange);
|
||||||
|
_libPrewarmDetachPriorityListeners = () => {
|
||||||
|
window.removeEventListener('odysseus:chat-busy-change', handlePriorityChange);
|
||||||
|
document.removeEventListener('visibilitychange', handlePriorityChange);
|
||||||
};
|
};
|
||||||
setTimeout(run, Math.max(0, Number(delay) || 0));
|
|
||||||
|
function requestIdle() {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
_libPrewarmDelayTimer = null;
|
||||||
|
try {
|
||||||
|
_libPrewarmIdleHandle = window.requestIdleCallback((deadline) => {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
_libPrewarmIdleHandle = null;
|
||||||
|
const hasIdleBudget = Boolean(
|
||||||
|
deadline
|
||||||
|
&& !deadline.didTimeout
|
||||||
|
&& typeof deadline.timeRemaining === 'function'
|
||||||
|
&& deadline.timeRemaining() > 0
|
||||||
|
);
|
||||||
|
if (!_canRunEmailPrewarm()) {
|
||||||
|
if (_isEmailPrewarmTemporarilyBlocked()) {
|
||||||
|
scheduleIdleRetry();
|
||||||
|
} else {
|
||||||
|
_settleEmailPrewarm(generation, false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasIdleBudget) {
|
||||||
|
scheduleIdleRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (generation !== _libPrewarmGeneration) {
|
||||||
|
_settleEmailPrewarm(generation, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
_libPrewarmAbortController = controller;
|
||||||
|
attemptPending = true;
|
||||||
|
retryRequested = false;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => task({ signal: controller.signal, generation }))
|
||||||
|
.then(value => {
|
||||||
|
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
|
||||||
|
_settleEmailPrewarm(generation, Boolean(value));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
|
||||||
|
_settleEmailPrewarm(generation, false);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
attemptPending = false;
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
if (retryRequested) scheduleIdleRetry();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
_settleEmailPrewarm(generation, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = Math.max(0, Number(delay) || 0);
|
||||||
|
if (wait > 0) _libPrewarmDelayTimer = setTimeout(requestIdle, wait);
|
||||||
|
else requestIdle();
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
|
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
|
||||||
if (_libPrewarmTimer || _libPrewarmPromise) return;
|
if (_libPrewarmPromise) return _libPrewarmPromise;
|
||||||
const elapsed = Date.now() - _libLastPrewarmAt;
|
const elapsed = Date.now() - _libLastPrewarmAt;
|
||||||
if (elapsed >= 0 && elapsed < 5 * 60 * 1000) return;
|
if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false);
|
||||||
_libPrewarmTimer = setTimeout(() => {
|
return _scheduleEmailPrewarm(_prewarmEmailViews, { delay });
|
||||||
_libPrewarmTimer = null;
|
|
||||||
_libPrewarmPromise = _prewarmEmailViews()
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => { _libPrewarmPromise = null; });
|
|
||||||
}, Math.max(0, Number(delay) || 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _ensureEmailAccountsForPrewarm() {
|
function _chooseEmailPrewarmAccountId(accounts) {
|
||||||
|
const enabled = Array.isArray(accounts) ? accounts.filter(a => a && a.enabled !== false) : [];
|
||||||
|
const remembered = _rememberedEmailAccountId();
|
||||||
|
const current = String(state._libAccountId || '').trim();
|
||||||
|
const chosen = enabled.find(a => String(a.id || '') === remembered)
|
||||||
|
|| enabled.find(a => String(a.id || '') === current)
|
||||||
|
|| enabled.find(a => a.is_default)
|
||||||
|
|| enabled[0]
|
||||||
|
|| null;
|
||||||
|
return String(chosen?.id || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _ensureEmailAccountsForPrewarm({ signal, generation } = {}) {
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||||
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
|
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
|
||||||
if (Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh) {
|
if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) {
|
||||||
if (!state._libAccountId) {
|
|
||||||
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
|
||||||
state._libAccountId = def?.id || null;
|
|
||||||
_publishActiveAccount();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, {
|
||||||
if (!accountsRes.ok) return;
|
credentials: 'same-origin',
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||||
|
if (accountsRes.ok) {
|
||||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||||
if (Array.isArray(accountsData.accounts)) {
|
if (Array.isArray(accountsData.accounts)) {
|
||||||
state._libAccounts = accountsData.accounts;
|
state._libAccounts = accountsData.accounts;
|
||||||
_libAccountsLoadedAt = Date.now();
|
_libAccountsLoadedAt = Date.now();
|
||||||
if (!state._libAccountId && state._libAccounts.length) {
|
|
||||||
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
|
||||||
state._libAccountId = def?.id || null;
|
|
||||||
_publishActiveAccount();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError') return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
const accountId = _chooseEmailPrewarmAccountId(state._libAccounts);
|
||||||
if (state._libOpen) return;
|
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||||
await _ensureEmailAccountsForPrewarm();
|
if (!accountId) return null;
|
||||||
if (state._libOpen) return;
|
if (accountId && state._libAccountId !== accountId) {
|
||||||
const accountId = state._libAccountId || '';
|
state._libAccountId = accountId;
|
||||||
|
_publishActiveAccount();
|
||||||
|
}
|
||||||
|
return accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
||||||
|
return _scheduleEmailPrewarm(
|
||||||
|
context => _prewarmUnreadEmailsNow({ limit, maxUid }, context),
|
||||||
|
{ delay: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _prewarmUnreadEmailsNow({ limit = 8, maxUid = 0 } = {}, { signal, generation } = {}) {
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
|
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
|
||||||
|
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
const n = Math.max(1, Math.min(20, Number(limit) || 8));
|
const n = Math.max(1, Math.min(20, Number(limit) || 8));
|
||||||
const key = `${accountId}|${maxUid || 0}|${n}`;
|
const key = `${accountId}|${maxUid || 0}|${n}`;
|
||||||
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return;
|
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true;
|
||||||
_libUnreadPrewarmKey = key;
|
|
||||||
_libUnreadPrewarmAt = Date.now();
|
|
||||||
try {
|
try {
|
||||||
const folder = 'INBOX';
|
const folder = 'INBOX';
|
||||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||||
@@ -2144,94 +2361,73 @@ export async function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
filter: 'unread',
|
filter: 'unread',
|
||||||
account_id: accountId || undefined,
|
account_id: accountId || undefined,
|
||||||
}), { credentials: 'same-origin' });
|
}), {
|
||||||
if (state._libOpen) return;
|
credentials: 'same-origin',
|
||||||
if (!res.ok) return;
|
signal,
|
||||||
|
});
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return;
|
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
|
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return false;
|
||||||
const sync = data.sync || {};
|
const sync = data.sync || {};
|
||||||
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
|
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
|
||||||
emails: data.emails,
|
emails: data.emails,
|
||||||
total: data.total || data.emails.length,
|
total: data.total || data.emails.length,
|
||||||
sync,
|
sync,
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
_libUnreadPrewarmKey = key;
|
||||||
|
_libUnreadPrewarmAt = Date.now();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _sleep(ms) {
|
async function _prewarmEmailViews({ signal, generation } = {}) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
}
|
|
||||||
|
|
||||||
async function _prewarmEmailViews() {
|
|
||||||
if (state._libOpen) return;
|
|
||||||
_libLastPrewarmAt = Date.now();
|
|
||||||
_setEmailSyncStatus({ warming: true });
|
_setEmailSyncStatus({ warming: true });
|
||||||
const folder = 'INBOX';
|
const folder = 'INBOX';
|
||||||
const filter = 'all';
|
const filter = 'all';
|
||||||
|
|
||||||
// The accounts request is cheap and warms the account strip for first open.
|
|
||||||
// Then folder/list requests warm both the client cache and the backend
|
|
||||||
// IMAP/read caches. Failure stays silent: no configured mail should not nag.
|
|
||||||
try {
|
try {
|
||||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
|
||||||
if (accountsRes.ok) {
|
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
|
||||||
if (Array.isArray(accountsData.accounts)) {
|
|
||||||
state._libAccounts = accountsData.accounts;
|
|
||||||
_libAccountsLoadedAt = Date.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.filter(a => a && a.enabled !== false) : [];
|
|
||||||
const preferred = state._libAccountId
|
|
||||||
|| (accounts.find(a => a.is_default)?.id)
|
|
||||||
|| (accounts[0]?.id)
|
|
||||||
|| '';
|
|
||||||
if (!state._libAccountId && preferred) {
|
|
||||||
state._libAccountId = preferred;
|
|
||||||
_publishActiveAccount();
|
|
||||||
}
|
|
||||||
const orderedAccountIds = [
|
|
||||||
preferred,
|
|
||||||
...accounts.map(a => a.id).filter(id => id && id !== preferred),
|
|
||||||
].filter((id, idx, arr) => arr.indexOf(id) === idx);
|
|
||||||
if (!orderedAccountIds.length) orderedAccountIds.push('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const accountId of orderedAccountIds.slice(0, 4)) {
|
|
||||||
if (state._libOpen) return;
|
|
||||||
const ck = _libCacheKeyFor(accountId, folder, filter, false);
|
const ck = _libCacheKeyFor(accountId, folder, filter, false);
|
||||||
if (_libCacheGet(ck)) continue;
|
if (_libCacheGet(ck)) {
|
||||||
await fetch(emailApiUrl('/api/email/folders', { account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
|
_libLastPrewarmAt = Date.now();
|
||||||
await fetch(emailApiUrl('/api/email/unread-state', { folder, account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One optional first-page request only. Folder metadata, unread state, and
|
||||||
|
// other accounts remain demand-driven so startup cannot fan out into IMAP.
|
||||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||||
folder,
|
folder,
|
||||||
limit: 100,
|
limit: _LIB_INITIAL_PAGE_SIZE,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
filter,
|
filter,
|
||||||
account_id: accountId || undefined,
|
account_id: accountId || undefined,
|
||||||
}), {
|
}), {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (data && !data.error) {
|
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
|
if (!data || data.error || !Array.isArray(data.emails)) return false;
|
||||||
const sync = data.sync || {};
|
const sync = data.sync || {};
|
||||||
_libCachePut(ck, {
|
_libCachePut(ck, {
|
||||||
emails: data.emails || [],
|
emails: data.emails,
|
||||||
total: data.total || 0,
|
total: data.total || 0,
|
||||||
sync,
|
sync,
|
||||||
});
|
});
|
||||||
|
_libLastPrewarmAt = Date.now();
|
||||||
_setEmailSyncStatus({
|
_setEmailSyncStatus({
|
||||||
updatedAt: sync.updated_at || new Date().toISOString(),
|
updatedAt: sync.updated_at || new Date().toISOString(),
|
||||||
source: sync.source || '',
|
source: sync.source || '',
|
||||||
warming: true,
|
warming: true,
|
||||||
});
|
});
|
||||||
}
|
return true;
|
||||||
}
|
} catch (_) {
|
||||||
await _sleep(900);
|
return false;
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
_setEmailSyncStatus({ warming: false });
|
_setEmailSyncStatus({ warming: false });
|
||||||
}
|
}
|
||||||
@@ -2286,16 +2482,34 @@ function _publishActiveAccount() {
|
|||||||
|
|
||||||
export function initEmailLibrary(config) {
|
export function initEmailLibrary(config) {
|
||||||
state._docModule = config.documentModule;
|
state._docModule = config.documentModule;
|
||||||
state._onEmailClick = config.onEmailClick;
|
const onEmailClick = config.onEmailClick;
|
||||||
|
state._onEmailClick = typeof onEmailClick === 'function' ? (options = {}) => {
|
||||||
|
const accountId = String(state._libAccountId || '');
|
||||||
|
const libraryFolder = String(state._libFolder || 'INBOX');
|
||||||
|
const messageFolder = String(options.email?.folder || libraryFolder);
|
||||||
|
const mailboxGeneration = _emailMailboxGeneration;
|
||||||
|
const mailboxContext = Object.freeze({
|
||||||
|
accountId,
|
||||||
|
libraryFolder,
|
||||||
|
messageFolder,
|
||||||
|
mailboxGeneration,
|
||||||
|
isCurrent: () => (
|
||||||
|
String(state._libAccountId || '') === accountId &&
|
||||||
|
String(state._libFolder || 'INBOX') === libraryFolder &&
|
||||||
|
_emailMailboxGeneration === mailboxGeneration
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return onEmailClick({ ...options, mailboxContext });
|
||||||
|
} : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isOpen() { return state._libOpen; }
|
export function isOpen() { return state._libOpen; }
|
||||||
|
|
||||||
export function openEmailLibrary(opts = {}) {
|
export function openEmailLibrary(opts = {}) {
|
||||||
if (_libPrewarmTimer) {
|
// Foreground email always wins: cancel a delayed/idle callback and abort the
|
||||||
clearTimeout(_libPrewarmTimer);
|
// one optional request if it has already started. Generation checks make a
|
||||||
_libPrewarmTimer = null;
|
// non-abortable response harmless if it races this transition.
|
||||||
}
|
_cancelEmailPrewarm();
|
||||||
// Force-clean any stale state from previous attempts
|
// Force-clean any stale state from previous attempts
|
||||||
const existing = document.getElementById('email-lib-modal');
|
const existing = document.getElementById('email-lib-modal');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
@@ -2303,6 +2517,7 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
document.removeEventListener('keydown', state._libEscHandler, true);
|
document.removeEventListener('keydown', state._libEscHandler, true);
|
||||||
state._libEscHandler = null;
|
state._libEscHandler = null;
|
||||||
}
|
}
|
||||||
|
_emailMailboxGeneration += 1;
|
||||||
state._libOpen = true;
|
state._libOpen = true;
|
||||||
// On mobile the sidebar overlays content — close it so the email view isn't
|
// On mobile the sidebar overlays content — close it so the email view isn't
|
||||||
// opened behind it (same pattern as session-switch/delete).
|
// opened behind it (same pattern as session-switch/delete).
|
||||||
@@ -2926,7 +3141,7 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
}
|
}
|
||||||
const fastAccountAtOpen = state._libAccountId || '';
|
const fastAccountAtOpen = state._libAccountId || '';
|
||||||
if (fastAccountAtOpen) {
|
if (fastAccountAtOpen) {
|
||||||
_loadEmailsWhenChatIdle({ delay: 0 });
|
_loadEmails({ useCache: true });
|
||||||
}
|
}
|
||||||
// If we already know the previous/default account, paint that inbox first
|
// If we already know the previous/default account, paint that inbox first
|
||||||
// from the durable index and validate accounts in parallel. Cold refreshes
|
// from the durable index and validate accounts in parallel. Cold refreshes
|
||||||
@@ -2936,7 +3151,7 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
_loadFolders();
|
_loadFolders();
|
||||||
_loadEmailReminderBellVisibility();
|
_loadEmailReminderBellVisibility();
|
||||||
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
|
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
|
||||||
_loadEmailsWhenChatIdle();
|
_loadEmails({ useCache: true });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -3121,6 +3336,7 @@ export async function openEmailLibrarySettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function closeEmailLibrary() {
|
export function closeEmailLibrary() {
|
||||||
|
_cancelEmailPrewarm();
|
||||||
const modal = document.getElementById('email-lib-modal');
|
const modal = document.getElementById('email-lib-modal');
|
||||||
if (modal) modal.remove();
|
if (modal) modal.remove();
|
||||||
if (_libSyncTicker) {
|
if (_libSyncTicker) {
|
||||||
@@ -4554,7 +4770,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 450);
|
const timer = setTimeout(() => ctrl.abort(), 450);
|
||||||
try {
|
try {
|
||||||
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
|
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
const fastData = await fastRes.json().catch(() => null);
|
const fastData = await fastRes.json().catch(() => null);
|
||||||
@@ -4581,7 +4797,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
|||||||
// opens omit it so rapid close/reopen returns instantly; the
|
// opens omit it so rapid close/reopen returns instantly; the
|
||||||
// Refresh button passes `force: true` to add it back.
|
// Refresh button passes `force: true` to add it back.
|
||||||
const buster = force ? `&_=${Date.now()}` : '';
|
const buster = force ? `&_=${Date.now()}` : '';
|
||||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
|
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
|
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
@@ -4836,6 +5052,8 @@ function _createCard(em) {
|
|||||||
else if (!em.is_read) cls += ' email-card-unread';
|
else if (!em.is_read) cls += ' email-card-unread';
|
||||||
card.className = cls;
|
card.className = cls;
|
||||||
card.dataset.uid = String(em.uid);
|
card.dataset.uid = String(em.uid);
|
||||||
|
card.dataset.emailAccount = String(em.account_id || state._libAccountId || '');
|
||||||
|
card.dataset.emailFolder = String(em.folder || state._libFolder || 'INBOX');
|
||||||
if (state._selectMode && state._selectedUids.has(em.uid)) card.classList.add('selected');
|
if (state._selectMode && state._selectedUids.has(em.uid)) card.classList.add('selected');
|
||||||
|
|
||||||
// Checkbox in select mode
|
// Checkbox in select mode
|
||||||
@@ -5162,6 +5380,25 @@ async function _toggleCardPreview(card, em) {
|
|||||||
// currently-selected folder for normal inbox cards.
|
// currently-selected folder for normal inbox cards.
|
||||||
const folderAtStart = (em && em.folder) || libraryFolderAtStart;
|
const folderAtStart = (em && em.folder) || libraryFolderAtStart;
|
||||||
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
|
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
|
||||||
|
const wasReadAtStart = !!em?.is_read;
|
||||||
|
const openGeneration = ++_emailCardOpenSeq;
|
||||||
|
const readContext = Object.freeze({
|
||||||
|
accountId: String(accountAtStart),
|
||||||
|
libraryFolder: String(libraryFolderAtStart),
|
||||||
|
folder: String(folderAtStart),
|
||||||
|
uid: uidAtStart,
|
||||||
|
mailboxGeneration: _emailMailboxGeneration,
|
||||||
|
});
|
||||||
|
const readContextKey = _emailReadContextKey(readContext);
|
||||||
|
const isCurrentOpen = () => (
|
||||||
|
openGeneration === _emailCardOpenSeq &&
|
||||||
|
_emailReadContextIsCurrent(readContext) &&
|
||||||
|
accountAtStart === (state._libAccountId || '') &&
|
||||||
|
libraryFolderAtStart === (state._libFolder || 'INBOX') &&
|
||||||
|
uidAtStart === String(card?.dataset?.uid || '') &&
|
||||||
|
card.isConnected &&
|
||||||
|
card.classList.contains('email-card-expanded')
|
||||||
|
);
|
||||||
const grid = card.closest('.doclib-grid');
|
const grid = card.closest('.doclib-grid');
|
||||||
const gridRect = grid?.getBoundingClientRect?.();
|
const gridRect = grid?.getBoundingClientRect?.();
|
||||||
const modal = document.getElementById('email-lib-modal');
|
const modal = document.getElementById('email-lib-modal');
|
||||||
@@ -5186,6 +5423,30 @@ async function _toggleCardPreview(card, em) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every authoritative open supersedes any older optimistic mutation for the
|
||||||
|
// same immutable mailbox identity. Carry the original unread state forward
|
||||||
|
// so a close/reopen followed by failure still rolls back exactly once, while
|
||||||
|
// a late failure from the superseded request cannot undo a newer success.
|
||||||
|
const previousMutation = _emailReadMutations.get(readContextKey);
|
||||||
|
const readMutation = {
|
||||||
|
generation: ++_emailReadMutationSeq,
|
||||||
|
rollbackUnread: !wasReadAtStart || !!previousMutation?.rollbackUnread,
|
||||||
|
};
|
||||||
|
_emailReadMutations.set(readContextKey, readMutation);
|
||||||
|
const restoreUnreadState = () => {
|
||||||
|
if (_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation) return;
|
||||||
|
_emailReadMutations.delete(readContextKey);
|
||||||
|
if (readMutation.rollbackUnread) _syncEmailReadState(uidAtStart, false, readContext);
|
||||||
|
};
|
||||||
|
const commitReadState = () => {
|
||||||
|
// A successful STORE/mark_seen is authoritative for this immutable
|
||||||
|
// mailbox identity even when a newer open is still pending. Retire that
|
||||||
|
// newer rollback token too, otherwise its later failure could restore an
|
||||||
|
// unread state that no longer exists at the provider.
|
||||||
|
_emailReadMutations.delete(readContextKey);
|
||||||
|
_syncEmailReadState(uidAtStart, true, readContext);
|
||||||
|
};
|
||||||
|
|
||||||
// Collapse any other expanded card
|
// Collapse any other expanded card
|
||||||
if (grid) {
|
if (grid) {
|
||||||
grid.querySelectorAll('.email-card-expanded').forEach(c => {
|
grid.querySelectorAll('.email-card-expanded').forEach(c => {
|
||||||
@@ -5207,10 +5468,10 @@ async function _toggleCardPreview(card, em) {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
|
try { card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
|
||||||
});
|
});
|
||||||
if (!em.is_read) {
|
if (!wasReadAtStart) {
|
||||||
_syncEmailReadState(em.uid, true);
|
// Keep the current optimistic visual update, but let the read request below
|
||||||
fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' })
|
// own the provider-side \Seen transition. A failure restores unread state.
|
||||||
.catch(err => console.error('Failed to mark email read:', err));
|
_syncEmailReadState(uidAtStart, true, readContext);
|
||||||
}
|
}
|
||||||
// Class hook on the modal so the header-hide / padding rules work on
|
// Class hook on the modal so the header-hide / padding rules work on
|
||||||
// browsers without :has() support (Firefox mobile) — the :has() versions
|
// browsers without :has() support (Firefox mobile) — the :has() versions
|
||||||
@@ -5239,25 +5500,28 @@ async function _toggleCardPreview(card, em) {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let authoritativeReadSucceeded = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
|
const accountQueryAtStart = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||||
|
const res = await fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uidAtStart)}?folder=${encodeURIComponent(folderAtStart)}${accountQueryAtStart}&mark_seen=true`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (
|
|
||||||
accountAtStart !== (state._libAccountId || '') ||
|
|
||||||
libraryFolderAtStart !== (state._libFolder || 'INBOX') ||
|
|
||||||
uidAtStart !== String(card?.dataset?.uid || '') ||
|
|
||||||
!card.isConnected ||
|
|
||||||
!card.classList.contains('email-card-expanded')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
showFailedReader(`Failed to load email: ${data.error}`);
|
restoreUnreadState();
|
||||||
|
if (isCurrentOpen()) showFailedReader(`Failed to load email: ${data.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Mark as read locally
|
if (data.mark_seen_failed) {
|
||||||
_syncEmailReadState(em.uid, true);
|
// The body is authoritative even when the provider refused the \Seen
|
||||||
|
// transition. Render the message and roll the unread marker back so the
|
||||||
|
// list keeps telling the truth, rather than refusing to open a message
|
||||||
|
// we successfully read.
|
||||||
|
restoreUnreadState();
|
||||||
|
} else {
|
||||||
|
authoritativeReadSucceeded = true;
|
||||||
|
commitReadState();
|
||||||
|
}
|
||||||
|
if (!isCurrentOpen()) return;
|
||||||
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
|
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
|
||||||
|
|
||||||
// Build the attachments wrap using the shared helper so the signature-
|
// Build the attachments wrap using the shared helper so the signature-
|
||||||
@@ -5439,9 +5703,12 @@ async function _toggleCardPreview(card, em) {
|
|||||||
// Always stop bubbling so the card's click doesn't fire while reading.
|
// Always stop bubbling so the card's click doesn't fire while reading.
|
||||||
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
|
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!authoritativeReadSucceeded) restoreUnreadState();
|
||||||
|
if (isCurrentOpen()) {
|
||||||
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
|
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap a probable signature block in a collapsed <details> so it stops
|
* Wrap a probable signature block in a collapsed <details> so it stops
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// liveThinkingThrottle.js
|
||||||
|
//
|
||||||
|
// Pure trailing-edge coalescer for the live "thinking" block in chat.js.
|
||||||
|
//
|
||||||
|
// A reasoning stream delivers deltas far faster than a human can read them, and
|
||||||
|
// the only thing that matters on screen is the LATEST cumulative text. Committing
|
||||||
|
// every delta to the DOM makes the work grow with the length of the stream. This
|
||||||
|
// throttle collapses a burst of updates into one commit per `delay` ms, always
|
||||||
|
// carrying the most recent value.
|
||||||
|
//
|
||||||
|
// Timers are injected so the behaviour is testable without a browser or a clock:
|
||||||
|
//
|
||||||
|
// const throttle = createLiveThinkingThrottle(commit, { prepare, schedule, cancel });
|
||||||
|
//
|
||||||
|
// Lifecycle contract, which the terminal paths in chat.js depend on:
|
||||||
|
//
|
||||||
|
// update(value) queue `value`; schedule a commit if one is not already pending
|
||||||
|
// flush() commit any pending value NOW and drop the timer; returns whether
|
||||||
|
// a commit happened, so a clean flush cannot duplicate a commit
|
||||||
|
// cancel() drop the timer AND the pending value — nothing lands later
|
||||||
|
//
|
||||||
|
// `cancel()` is what stops a finished (or backgrounded) stream from mutating a
|
||||||
|
// view the user has since navigated away to.
|
||||||
|
|
||||||
|
export function stripLiveThinkingTags(text) {
|
||||||
|
return String(text ?? '').replace(
|
||||||
|
/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const THINKING_BOUNDARY_RE = /<\/?(?:(?:mm:)?think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>(?:thought|response)|<channel\|>/gi;
|
||||||
|
const REPLY_PREFIX_SOURCE = "(?:Hey|Hi |Hi!|Hello|Sure|Yes|No |No,|Yo|OK|Here|Absolutely|Of course|Great|Alright|Thanks|Welcome|Good |I'm happy|I'd be)";
|
||||||
|
const REPLY_LINE_RE = new RegExp('(?:^|\\n)\\s*' + REPLY_PREFIX_SOURCE, 'gi');
|
||||||
|
const REPLY_INLINE_RE = new RegExp('[.!?]\\s*' + REPLY_PREFIX_SOURCE, 'gi');
|
||||||
|
const REASONING_PREFIX_CANDIDATES = [
|
||||||
|
'thinking:', 'thinking process:', 'the user ', 'user wants', 'we need ',
|
||||||
|
'i need ', 'i should ', 'i will ', "i'll ", 'i am going ', 'let me think',
|
||||||
|
'let me look', 'let me see', 'let me check', 'let me read', 'let me review',
|
||||||
|
'let me analyze', 'let me parse', 'let me figure', 'let me draft', 'let me write',
|
||||||
|
'they are ', 'the question ', 'i can ',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DISPLAY_FILTER_BOUNDARY_RE = /\[\/?TOOL_CALL\]|```(?:create_document|documen(?:t)?)(?:\s|$)|```[\w-]+[ \t]*[\[{]|<(?:[\w]+:)?(?:tool_call|function_call)>|<invoke\b|<\s*\/?\s*[||]+\s*DSML\s*[||]+|(?:\[\s*)?\{\s*"function"\s*:|<\/?\|(?:assistant|assistan|user|system|tool|end)\|?>|(?:^|[\r\n])\s*(?:stdout|stderr|exit_code):/i;
|
||||||
|
|
||||||
|
function hasFreshMatch(text, regex, cursor, minStart = 0) {
|
||||||
|
regex.lastIndex = 0;
|
||||||
|
for (const match of text.matchAll(regex)) {
|
||||||
|
const end = match.index + match[0].length;
|
||||||
|
if (end > cursor && match.index >= minStart) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrementally decides when chat.js needs its compatibility-heavy cumulative
|
||||||
|
// thinking analysis. The gate inspects only a short overlap plus the new text;
|
||||||
|
// ordinary answer/reasoning deltas therefore stay O(delta) while split tags,
|
||||||
|
// namespaced tags, non-tag reply boundaries, and false-close grace deadlines
|
||||||
|
// still request the canonical full analysis.
|
||||||
|
export function createThinkingAnalysisGate({
|
||||||
|
startsWithReasoningPrefix = () => false,
|
||||||
|
now = () => Date.now(),
|
||||||
|
overlap = 512,
|
||||||
|
} = {}) {
|
||||||
|
let cursor = 0;
|
||||||
|
let prefixSettled = false;
|
||||||
|
let prefixProbe = '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldAnalyze(text, {
|
||||||
|
isThinking = false,
|
||||||
|
nonTagThinking = false,
|
||||||
|
recheckAt = 0,
|
||||||
|
} = {}) {
|
||||||
|
const fullText = String(text ?? '');
|
||||||
|
if (fullText.length < cursor) {
|
||||||
|
cursor = 0;
|
||||||
|
prefixSettled = false;
|
||||||
|
prefixProbe = '';
|
||||||
|
}
|
||||||
|
const previousCursor = cursor;
|
||||||
|
if (!prefixSettled && prefixProbe.length < overlap) {
|
||||||
|
// Build the initial probe from deltas so arbitrary leading whitespace
|
||||||
|
// cannot strand the gate in its undecided state. The retained state is
|
||||||
|
// bounded even if a provider emits a very large whitespace prefix.
|
||||||
|
prefixProbe = (prefixProbe + fullText.slice(previousCursor))
|
||||||
|
.trimStart()
|
||||||
|
.slice(0, overlap);
|
||||||
|
}
|
||||||
|
const scanStart = Math.max(0, previousCursor - overlap);
|
||||||
|
const freshText = fullText.slice(scanStart);
|
||||||
|
const relativeCursor = previousCursor - scanStart;
|
||||||
|
const hasBoundary = hasFreshMatch(freshText, THINKING_BOUNDARY_RE, relativeCursor);
|
||||||
|
const hasReplyBoundary = nonTagThinking && (
|
||||||
|
hasFreshMatch(freshText, REPLY_LINE_RE, relativeCursor)
|
||||||
|
|| hasFreshMatch(freshText, REPLY_INLINE_RE, relativeCursor, Math.max(0, 20 - scanStart))
|
||||||
|
);
|
||||||
|
cursor = fullText.length;
|
||||||
|
|
||||||
|
if (hasBoundary || hasReplyBoundary) return true;
|
||||||
|
if (isThinking) return recheckAt > 0 && now() >= recheckAt;
|
||||||
|
if (prefixSettled) return false;
|
||||||
|
|
||||||
|
if (!prefixProbe) return false;
|
||||||
|
if (startsWithReasoningPrefix(prefixProbe)) {
|
||||||
|
prefixSettled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const lowerProbe = prefixProbe.toLowerCase();
|
||||||
|
if (REASONING_PREFIX_CANDIDATES.some((candidate) => candidate.startsWith(lowerProbe))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
prefixSettled = true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
cursor = 0;
|
||||||
|
prefixSettled = false;
|
||||||
|
prefixProbe = '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the common prose path append-only. At the first structured/tool
|
||||||
|
// boundary, filter only the preceding visible prefix and hide the structured
|
||||||
|
// tail until the authoritative terminal render.
|
||||||
|
export function createIncrementalDisplayProjector(filter, { overlap = 512 } = {}) {
|
||||||
|
let projected = '';
|
||||||
|
let boundaryTail = '';
|
||||||
|
let rawLength = 0;
|
||||||
|
let structuredTailHidden = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
append(delta, fullText) {
|
||||||
|
const chunk = String(delta ?? '');
|
||||||
|
const raw = String(fullText ?? '');
|
||||||
|
if (raw.length < rawLength) this.reset();
|
||||||
|
const boundaryProbe = boundaryTail + chunk;
|
||||||
|
const boundaryMatch = !structuredTailHidden
|
||||||
|
? DISPLAY_FILTER_BOUNDARY_RE.exec(boundaryProbe)
|
||||||
|
: null;
|
||||||
|
if (boundaryMatch) {
|
||||||
|
// Filter the visible prefix, not the incomplete marker itself: several
|
||||||
|
// compatibility regexes intentionally match only completed blocks.
|
||||||
|
const boundaryStart = Math.max(0, raw.length - boundaryProbe.length + boundaryMatch.index);
|
||||||
|
structuredTailHidden = true;
|
||||||
|
projected = String(filter(raw.slice(0, boundaryStart)) ?? '');
|
||||||
|
} else if (!structuredTailHidden) {
|
||||||
|
projected += chunk;
|
||||||
|
}
|
||||||
|
boundaryTail = (boundaryTail + chunk).slice(-overlap);
|
||||||
|
rawLength = raw.length;
|
||||||
|
return projected;
|
||||||
|
},
|
||||||
|
current() {
|
||||||
|
return projected;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
projected = '';
|
||||||
|
boundaryTail = '';
|
||||||
|
rawLength = 0;
|
||||||
|
structuredTailHidden = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLiveThinkingThrottle(commit, {
|
||||||
|
delay = 100,
|
||||||
|
prepare = (value) => String(value ?? ''),
|
||||||
|
schedule = (callback, ms) => setTimeout(callback, ms),
|
||||||
|
cancel = (timer) => clearTimeout(timer),
|
||||||
|
} = {}) {
|
||||||
|
let timer = null;
|
||||||
|
let latest = null;
|
||||||
|
let dirty = false;
|
||||||
|
|
||||||
|
const commitLatest = () => {
|
||||||
|
timer = null;
|
||||||
|
if (!dirty) return false;
|
||||||
|
dirty = false;
|
||||||
|
commit(prepare(latest));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(value) {
|
||||||
|
latest = value;
|
||||||
|
dirty = true;
|
||||||
|
if (timer === null) timer = schedule(commitLatest, delay);
|
||||||
|
},
|
||||||
|
flush() {
|
||||||
|
if (timer !== null) {
|
||||||
|
cancel(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
return commitLatest();
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (timer !== null) cancel(timer);
|
||||||
|
timer = null;
|
||||||
|
dirty = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createLiveThinkingThrottle;
|
||||||
@@ -1683,8 +1683,21 @@ export async function loadSessions() {
|
|||||||
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
|
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
|
||||||
}
|
}
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
const payload = await res.json();
|
||||||
|
detail = payload?.detail || payload?.error || '';
|
||||||
|
} catch (_) {}
|
||||||
|
const error = new Error(detail || `Session request failed (HTTP ${res.status})`);
|
||||||
|
error.status = res.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
fetched = await res.json();
|
fetched = await res.json();
|
||||||
}
|
}
|
||||||
|
if (!Array.isArray(fetched)) {
|
||||||
|
throw new Error('Session request returned an invalid response');
|
||||||
|
}
|
||||||
sessions = _normalizeSessionsList(fetched);
|
sessions = _normalizeSessionsList(fetched);
|
||||||
renderSessionList();
|
renderSessionList();
|
||||||
|
|
||||||
@@ -1807,10 +1820,16 @@ export async function loadSessions() {
|
|||||||
_autoCreateInProgress = false;
|
_autoCreateInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in loadSessions:', error);
|
console.error('Error in loadSessions:', error);
|
||||||
|
// app.js's global fetch wrapper owns expired-auth navigation. Avoid
|
||||||
|
// flashing a redundant session error while that 401 redirect is pending.
|
||||||
|
if (error?.status !== 401) {
|
||||||
uiModule.showError('Failed to load sessions: ' + error.message);
|
uiModule.showError('Failed to load sessions: ' + error.message);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function selectSession(id, { keepSidebar = false, showLoading = true, immediateLoading = false } = {}) {
|
export async function selectSession(id, { keepSidebar = false, showLoading = true, immediateLoading = false } = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Odysseus UI — startup shell sequencing
|
||||||
|
// ES6 module — no application dependencies, DOM only.
|
||||||
|
//
|
||||||
|
// Revealing the application shell, retiring the boot loader, settling the
|
||||||
|
// sidebar's own loading state, and firing a deferred URL route are separate
|
||||||
|
// startup concerns that used to sit inline in app.js behind a single promise.
|
||||||
|
// They live here so each step has one owner and so the whole contract can be
|
||||||
|
// exercised directly (tests/test_startup_shell_js.py) without booting the app.
|
||||||
|
|
||||||
|
const LOADER_ID = 'app-loader';
|
||||||
|
const SESSION_BOOTSTRAP_ROW_ID = 'session-list-loading';
|
||||||
|
|
||||||
|
// Route openers that read the hydrated session list. Everything else only
|
||||||
|
// needs module wiring and must not wait on /api/sessions. `/email` spawns a
|
||||||
|
// fresh chat, and that path falls back to the most recent session's model
|
||||||
|
// (_createDirectChatFromPreferredModel in app.js) when there is no default
|
||||||
|
// chat configured, so it genuinely needs the list.
|
||||||
|
const ROUTES_NEEDING_SESSIONS = new Set(['/email']);
|
||||||
|
|
||||||
|
let _routeOpener = null;
|
||||||
|
let _routeOpenerNeedsSessions = false;
|
||||||
|
|
||||||
|
function _loader() {
|
||||||
|
return document.getElementById(LOADER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `fn` after the next paint has committed (two animation frames). */
|
||||||
|
export function afterNextPaint(fn) {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(fn));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The loader node stays in the DOM while sessions hydrate — sidebar-layout.js
|
||||||
|
// and sessions.js both read its presence as a "still starting up" sentinel —
|
||||||
|
// but it must stop covering, announcing, and animating over a usable shell.
|
||||||
|
function _makeLoaderInert(loader) {
|
||||||
|
if (!loader || loader.dataset.shellRevealed === 'true') return;
|
||||||
|
loader.dataset.shellRevealed = 'true';
|
||||||
|
loader.setAttribute('aria-hidden', 'true');
|
||||||
|
loader.style.pointerEvents = 'none';
|
||||||
|
loader.style.opacity = '0';
|
||||||
|
// index.html's inline bootstrap animates the wave on a 150ms interval.
|
||||||
|
// Nothing of it is visible any more, so stop rendering into it.
|
||||||
|
try { window.__odysseusLoaderWaveStop?.(); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the shell to the user once core wiring is done. Deferred by one paint
|
||||||
|
* so the first frame lands with the app already laid out.
|
||||||
|
*/
|
||||||
|
export function revealApplicationShellAfterPaint() {
|
||||||
|
const loader = _loader();
|
||||||
|
if (!loader || loader.dataset.shellRevealScheduled === 'true') return;
|
||||||
|
loader.dataset.shellRevealScheduled = 'true';
|
||||||
|
afterNextPaint(() => _makeLoaderInert(_loader()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retire the loader node for good. Safe to call after a reveal. */
|
||||||
|
export function removeApplicationLoader() {
|
||||||
|
const loader = _loader();
|
||||||
|
if (!loader) return;
|
||||||
|
_makeLoaderInert(loader);
|
||||||
|
setTimeout(() => loader.remove(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the sidebar's bootstrap row into a failure row. The write is delayed
|
||||||
|
* until the session renderer's frame has committed so a late success cannot
|
||||||
|
* leave stale failure text behind.
|
||||||
|
*/
|
||||||
|
export function markSessionListUnavailableIfStillBootstrapping() {
|
||||||
|
afterNextPaint(() => {
|
||||||
|
const row = document.getElementById(SESSION_BOOTSTRAP_ROW_ID);
|
||||||
|
if (!row) return;
|
||||||
|
const status = row.querySelector('[data-session-list-status]') || row;
|
||||||
|
status.textContent = 'Chats unavailable';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `path`'s route opener reads the hydrated session list. */
|
||||||
|
export function routeNeedsSessionData(path) {
|
||||||
|
return ROUTES_NEEDING_SESSIONS.has(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash a URL route opener for later. At the point app.js resolves the route,
|
||||||
|
* the modules its handlers drive (the rail new-chat handler, the email
|
||||||
|
* section header handler, sessionModule) are still being wired further down
|
||||||
|
* the same init pass, so the opener cannot run inline.
|
||||||
|
*/
|
||||||
|
export function deferRouteOpener(path, opener) {
|
||||||
|
if (!opener) return;
|
||||||
|
_routeOpener = opener;
|
||||||
|
_routeOpenerNeedsSessions = routeNeedsSessionData(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the deferred route opener if its data is ready. Called once when
|
||||||
|
* wiring completes and again after authoritative session hydration; a route
|
||||||
|
* that needs no session data takes the first call, one that does takes the
|
||||||
|
* second.
|
||||||
|
*
|
||||||
|
* @returns {boolean} whether an opener ran.
|
||||||
|
*/
|
||||||
|
export function runDeferredRouteOpener({ sessionsSettled = false } = {}) {
|
||||||
|
if (!_routeOpener) return false;
|
||||||
|
if (_routeOpenerNeedsSessions && !sessionsSettled) return false;
|
||||||
|
const opener = _routeOpener;
|
||||||
|
_routeOpener = null;
|
||||||
|
_routeOpenerNeedsSessions = false;
|
||||||
|
try { opener(); } catch (e) { console.warn('route opener failed:', e); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive session hydration and everything that hangs off it settling: the
|
||||||
|
* sidebar's failure row, the loader node, and any session-dependent route.
|
||||||
|
*
|
||||||
|
* @param {(() => Promise<boolean>)|null} loadSessions — resolves true only
|
||||||
|
* after the session list was authoritatively loaded and applied. Null means
|
||||||
|
* the session module failed to load.
|
||||||
|
*/
|
||||||
|
export function settleSessionHydration(loadSessions) {
|
||||||
|
const settle = (succeeded) => {
|
||||||
|
if (!succeeded) {
|
||||||
|
markSessionListUnavailableIfStillBootstrapping();
|
||||||
|
// A later unrelated caller must not be able to release a stale startup
|
||||||
|
// opener against unknown session state.
|
||||||
|
_routeOpener = null;
|
||||||
|
_routeOpenerNeedsSessions = false;
|
||||||
|
}
|
||||||
|
removeApplicationLoader();
|
||||||
|
if (succeeded) runDeferredRouteOpener({ sessionsSettled: true });
|
||||||
|
return succeeded;
|
||||||
|
};
|
||||||
|
if (!loadSessions) {
|
||||||
|
return Promise.resolve(settle(false));
|
||||||
|
}
|
||||||
|
// Kick the request off synchronously — a microtask hop here would delay the
|
||||||
|
// fetch this whole change exists to get off the critical path.
|
||||||
|
let pending;
|
||||||
|
try {
|
||||||
|
pending = loadSessions();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('loadSessions error:', e);
|
||||||
|
return Promise.resolve(settle(false));
|
||||||
|
}
|
||||||
|
return Promise.resolve(pending)
|
||||||
|
.then(result => settle(result === true))
|
||||||
|
.catch(e => {
|
||||||
|
console.warn('loadSessions error:', e);
|
||||||
|
return settle(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38015,6 +38015,12 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
/* Bootstrap row shown while the session list hydrates, and on load failure.
|
||||||
|
Reads as a normal list row but is not selectable. */
|
||||||
|
.session-list-bootstrap {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
#email-lib-grid .date-section-header {
|
#email-lib-grid .date-section-header {
|
||||||
padding: 10px 5px 3px;
|
padding: 10px 5px 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// Tests for the live-thinking throttle that bounds DOM work during long
|
||||||
|
// reasoning streams (see static/js/liveThinkingThrottle.js).
|
||||||
|
//
|
||||||
|
// The throttle's contract is what the terminal paths in chat.js lean on:
|
||||||
|
// a burst of deltas becomes ONE commit carrying the latest text; flush()
|
||||||
|
// lands trailing text synchronously and cannot double-commit; cancel()
|
||||||
|
// guarantees nothing lands after a stream is finished or backgrounded.
|
||||||
|
//
|
||||||
|
// Timers are injected, so this runs with no DOM and no real clock.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createIncrementalDisplayProjector,
|
||||||
|
createLiveThinkingThrottle,
|
||||||
|
createThinkingAnalysisGate,
|
||||||
|
stripLiveThinkingTags,
|
||||||
|
} from '../static/js/liveThinkingThrottle.js';
|
||||||
|
|
||||||
|
function fakeTimers() {
|
||||||
|
let nextId = 1;
|
||||||
|
const callbacks = new Map();
|
||||||
|
const delays = [];
|
||||||
|
return {
|
||||||
|
schedule(callback, delay) {
|
||||||
|
const id = nextId++;
|
||||||
|
callbacks.set(id, callback);
|
||||||
|
delays.push(delay);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
cancel(id) {
|
||||||
|
callbacks.delete(id);
|
||||||
|
},
|
||||||
|
run(id) {
|
||||||
|
const callback = callbacks.get(id);
|
||||||
|
assert.ok(callback, `missing timer ${id}`);
|
||||||
|
callbacks.delete(id);
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
pendingIds() {
|
||||||
|
return [...callbacks.keys()];
|
||||||
|
},
|
||||||
|
delays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('coalesces a burst and commits only the latest text after 100 ms', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const commits = [];
|
||||||
|
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||||
|
|
||||||
|
throttle.update('a');
|
||||||
|
throttle.update('ab');
|
||||||
|
throttle.update('abc');
|
||||||
|
|
||||||
|
assert.deepEqual(commits, []);
|
||||||
|
assert.deepEqual(timers.delays, [100], 'a burst must schedule exactly one commit');
|
||||||
|
const [timer] = timers.pendingIds();
|
||||||
|
timers.run(timer);
|
||||||
|
assert.deepEqual(commits, ['abc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('commit count stays flat as the stream grows', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const commits = [];
|
||||||
|
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||||
|
|
||||||
|
// 500 deltas arriving inside one window is the regression this guards:
|
||||||
|
// the old code committed once per delta, so work grew with stream length.
|
||||||
|
let text = '';
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
text += 'token ';
|
||||||
|
throttle.update(text);
|
||||||
|
}
|
||||||
|
assert.deepEqual(commits, []);
|
||||||
|
assert.equal(timers.pendingIds().length, 1);
|
||||||
|
timers.run(timers.pendingIds()[0]);
|
||||||
|
assert.equal(commits.length, 1);
|
||||||
|
assert.equal(commits[0], text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepares a 200K cumulative stream only at scheduled commit cadence', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const commits = [];
|
||||||
|
let prepareCalls = 0;
|
||||||
|
let scannedCharacters = 0;
|
||||||
|
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
|
||||||
|
...timers,
|
||||||
|
prepare(value) {
|
||||||
|
prepareCalls += 1;
|
||||||
|
scannedCharacters += value.length;
|
||||||
|
return stripLiveThinkingTags(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const delta = 'reasoning '.repeat(10); // 100 characters
|
||||||
|
let cumulative = '';
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
cumulative += delta;
|
||||||
|
throttle.update(cumulative);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(cumulative.length, 200_000);
|
||||||
|
assert.equal(prepareCalls, 0, 'cumulative extraction must not run per delta');
|
||||||
|
assert.equal(timers.pendingIds().length, 1);
|
||||||
|
timers.run(timers.pendingIds()[0]);
|
||||||
|
assert.equal(prepareCalls, 1);
|
||||||
|
assert.equal(scannedCharacters, 200_000);
|
||||||
|
assert.deepEqual(commits, [cumulative]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary answers and reasoning deltas do not request cumulative analysis', () => {
|
||||||
|
const startsReasoning = (text) => /^\s*thinking(?:\s+process)?\s*:/i.test(text);
|
||||||
|
const ordinaryGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
|
||||||
|
let ordinary = '';
|
||||||
|
let ordinaryAnalyses = 0;
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
ordinary += i === 0 ? 'Here is the answer. ' : 'answer '.repeat(10);
|
||||||
|
if (ordinaryGate.shouldAnalyze(ordinary)) ordinaryAnalyses += 1;
|
||||||
|
}
|
||||||
|
assert.equal(ordinaryAnalyses, 0);
|
||||||
|
|
||||||
|
const thinkingGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
|
||||||
|
let thinking = 'Thin';
|
||||||
|
assert.equal(thinkingGate.shouldAnalyze(thinking), false);
|
||||||
|
thinking += 'king: inspect the problem';
|
||||||
|
assert.equal(thinkingGate.shouldAnalyze(thinking), true);
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
thinking += ' reasoning'.repeat(10);
|
||||||
|
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), false);
|
||||||
|
}
|
||||||
|
thinking += '\n\nHere is the answer';
|
||||||
|
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), true);
|
||||||
|
|
||||||
|
const whitespaceGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
|
||||||
|
let whitespaceThinking = ' '.repeat(250);
|
||||||
|
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), false);
|
||||||
|
whitespaceThinking += 'Thinking: bounded probe';
|
||||||
|
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('split namespaced closes and false-close deadlines request analysis', () => {
|
||||||
|
let clock = 100;
|
||||||
|
const gate = createThinkingAnalysisGate({ now: () => clock });
|
||||||
|
let text = '<mm:think>x</mm:';
|
||||||
|
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'fresh opening tag is analyzed');
|
||||||
|
text += 'think>answer';
|
||||||
|
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'split namespaced close is analyzed');
|
||||||
|
|
||||||
|
text += ' still waiting';
|
||||||
|
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), false);
|
||||||
|
clock = 500;
|
||||||
|
text += ' next delta';
|
||||||
|
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), true);
|
||||||
|
|
||||||
|
const attributedGate = createThinkingAnalysisGate();
|
||||||
|
let attributed = `<think data-provider="${'x'.repeat(400)}"`;
|
||||||
|
assert.equal(attributedGate.shouldAnalyze(attributed), false);
|
||||||
|
attributed += '>reasoning';
|
||||||
|
assert.equal(attributedGate.shouldAnalyze(attributed), true, 'bounded carry preserves split tag attributes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display projection is append-only and filters a structured tail once', () => {
|
||||||
|
let filterCalls = 0;
|
||||||
|
let filteredCharacters = 0;
|
||||||
|
const projector = createIncrementalDisplayProjector((text) => {
|
||||||
|
filterCalls += 1;
|
||||||
|
filteredCharacters += text.length;
|
||||||
|
return text.replace(/\[TOOL_CALL\][\s\S]*$/i, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
const delta = i === 0 ? 'Here is the answer. ' : 'ordinary text ';
|
||||||
|
text += delta;
|
||||||
|
assert.equal(projector.append(delta, text), text);
|
||||||
|
}
|
||||||
|
assert.equal(filterCalls, 0, 'ordinary deltas never run the cumulative filter');
|
||||||
|
|
||||||
|
text += '[TOOL_';
|
||||||
|
projector.append('[TOOL_', text);
|
||||||
|
text += 'CALL]{"name":"read"}';
|
||||||
|
const beforeToolPayload = projector.append('CALL]{"name":"read"}', text);
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
const delta = 'payload ';
|
||||||
|
text += delta;
|
||||||
|
assert.equal(projector.append(delta, text), beforeToolPayload);
|
||||||
|
}
|
||||||
|
assert.equal(filterCalls, 1, 'structured payload filtering happens only at its boundary');
|
||||||
|
assert.ok(filteredCharacters < text.length, 'filter work is bounded by the first structured boundary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('literal escaped tags survive and malformed live tags retain trailing text', () => {
|
||||||
|
assert.equal(
|
||||||
|
stripLiveThinkingTags('<think>literal</think>'),
|
||||||
|
'<think>literal</think>',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
stripLiveThinkingTags('<think>first</think> middle <thinking mode="deep">trailing'),
|
||||||
|
'first middle trailing',
|
||||||
|
);
|
||||||
|
assert.equal(stripLiveThinkingTags('answer with 2 < 3 and 5 > 4'), 'answer with 2 < 3 and 5 > 4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal flush prepares and commits the complete trailing cumulative text', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const commits = [];
|
||||||
|
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
|
||||||
|
...timers,
|
||||||
|
prepare: stripLiveThinkingTags,
|
||||||
|
});
|
||||||
|
|
||||||
|
throttle.update('<think>reasoning without a closing tag');
|
||||||
|
assert.equal(throttle.flush(), true);
|
||||||
|
assert.deepEqual(commits, ['reasoning without a closing tag']);
|
||||||
|
assert.deepEqual(timers.pendingIds(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('independent throttles cannot commit cancelled text into another session', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const commits = [];
|
||||||
|
const first = createLiveThinkingThrottle((value) => commits.push(['first', value]), timers);
|
||||||
|
const second = createLiveThinkingThrottle((value) => commits.push(['second', value]), timers);
|
||||||
|
|
||||||
|
first.update('stale first-session text');
|
||||||
|
second.update('current second-session text');
|
||||||
|
first.cancel();
|
||||||
|
assert.equal(second.flush(), true);
|
||||||
|
|
||||||
|
assert.deepEqual(timers.pendingIds(), []);
|
||||||
|
assert.deepEqual(commits, [['second', 'current second-session text']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flush synchronously preserves trailing text and cancels the pending callback', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const commits = [];
|
||||||
|
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||||
|
|
||||||
|
throttle.update('trailing text');
|
||||||
|
assert.equal(throttle.flush(), true);
|
||||||
|
assert.deepEqual(commits, ['trailing text']);
|
||||||
|
assert.deepEqual(timers.pendingIds(), []);
|
||||||
|
assert.equal(throttle.flush(), false, 'clean flush must not duplicate the commit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel discards pending work without a late DOM commit', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const commits = [];
|
||||||
|
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||||
|
|
||||||
|
throttle.update('stale session text');
|
||||||
|
throttle.cancel();
|
||||||
|
assert.deepEqual(timers.pendingIds(), []);
|
||||||
|
assert.deepEqual(commits, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cancelled throttle accepts new work again', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const commits = [];
|
||||||
|
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||||
|
|
||||||
|
throttle.update('discarded');
|
||||||
|
throttle.cancel();
|
||||||
|
throttle.update('fresh');
|
||||||
|
assert.equal(throttle.flush(), true);
|
||||||
|
assert.deepEqual(commits, ['fresh']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coerces nullish updates instead of committing undefined', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const commits = [];
|
||||||
|
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||||
|
|
||||||
|
throttle.update(null);
|
||||||
|
throttle.flush();
|
||||||
|
assert.deepEqual(commits, ['']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Windows execution contract for the agent Bash tool."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.agent_tools import subprocess_tools
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_windows_bash_uses_git_bash_with_structural_cwd(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
bash = r"C:\Program Files\Git\bin\bash.exe"
|
||||||
|
workspace = r"D:\Workspaces\Project with spaces"
|
||||||
|
process = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||||
|
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: bash)
|
||||||
|
|
||||||
|
async def fake_exec(*argv, **kwargs):
|
||||||
|
captured["argv"] = argv
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return process
|
||||||
|
|
||||||
|
async def fail_shell(*_args, **_kwargs):
|
||||||
|
pytest.fail("native Windows Bash must not execute through cmd.exe")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
|
||||||
|
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fail_shell)
|
||||||
|
|
||||||
|
result = await subprocess_tools._create_bash_subprocess(
|
||||||
|
"pwd; cat package.json",
|
||||||
|
cwd=workspace,
|
||||||
|
env={"HOME": r"C:\Odysseus\data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is process
|
||||||
|
assert captured["argv"] == (bash, "-c", "pwd; cat package.json")
|
||||||
|
assert captured["kwargs"]["cwd"] == workspace
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_windows_bash_without_git_bash_fails_clearly(monkeypatch):
|
||||||
|
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||||
|
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
|
||||||
|
|
||||||
|
async def fail_spawn(*_args, **_kwargs):
|
||||||
|
pytest.fail("no subprocess should start without Git Bash")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fail_spawn)
|
||||||
|
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fail_spawn)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Git Bash is required"):
|
||||||
|
await subprocess_tools._create_bash_subprocess("pwd", cwd=r"C:\Work")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||||
|
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
|
||||||
|
|
||||||
|
result = await subprocess_tools.BashTool().execute(
|
||||||
|
"pwd",
|
||||||
|
{"subproc_env": {}, "session_id": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["exit_code"] == 1
|
||||||
|
assert "install Git for Windows" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
workspace = r"D:\Workspaces\Project with spaces"
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess_tools.shutil,
|
||||||
|
"which",
|
||||||
|
lambda name: r"C:\msys64\usr\bin\tmux.exe",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: workspace)
|
||||||
|
|
||||||
|
async def fail_tmux(*_args, **_kwargs):
|
||||||
|
pytest.fail("native Windows must not enter the POSIX tmux path")
|
||||||
|
|
||||||
|
async def fake_create(command, **kwargs):
|
||||||
|
captured["command"] = command
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return object()
|
||||||
|
|
||||||
|
async def fake_stream(_process, **_kwargs):
|
||||||
|
return "ok", "", 0, False
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess_tools, "_run_tmux_bash", fail_tmux)
|
||||||
|
monkeypatch.setattr(subprocess_tools, "_create_bash_subprocess", fake_create)
|
||||||
|
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
|
||||||
|
|
||||||
|
result = await subprocess_tools.BashTool().execute(
|
||||||
|
"pwd",
|
||||||
|
{"subproc_env": {}, "session_id": "chat-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"output": "ok", "exit_code": 0}
|
||||||
|
assert captured["command"] == "pwd"
|
||||||
|
assert captured["kwargs"]["cwd"] == workspace
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_posix_bash_keeps_existing_shell_path(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
process = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", False)
|
||||||
|
|
||||||
|
async def fake_shell(command, **kwargs):
|
||||||
|
captured["command"] = command
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return process
|
||||||
|
|
||||||
|
async def fail_exec(*_args, **_kwargs):
|
||||||
|
pytest.fail("POSIX behavior must continue through create_subprocess_shell")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fake_shell)
|
||||||
|
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fail_exec)
|
||||||
|
|
||||||
|
result = await subprocess_tools._create_bash_subprocess("pwd", cwd="/tmp/work")
|
||||||
|
|
||||||
|
assert result is process
|
||||||
|
assert captured == {"command": "pwd", "kwargs": {"cwd": "/tmp/work"}}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ def test_stream_render_helpers_are_visible_to_catch_block():
|
|||||||
assert "let _cancelThinkingTimer = () => {};" in outer_scope
|
assert "let _cancelThinkingTimer = () => {};" in outer_scope
|
||||||
assert "let _removeThinkingSpinner = () => {};" in outer_scope
|
assert "let _removeThinkingSpinner = () => {};" in outer_scope
|
||||||
|
|
||||||
assert "_renderStream = () => {" in try_body
|
assert re.search(r"(?m)^\s*_renderStream\s*=", try_body)
|
||||||
assert "_cancelThinkingTimer = () => {" in try_body
|
assert "_cancelThinkingTimer = () => {" in try_body
|
||||||
assert "_removeThinkingSpinner = () => {" in try_body
|
assert "_removeThinkingSpinner = () => {" in try_body
|
||||||
assert "function _renderStream()" not in try_body
|
assert "function _renderStream()" not in try_body
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
|
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
|
||||||
|
|
||||||
|
|
||||||
|
def _source() -> str:
|
||||||
|
return _EMAIL_LIBRARY.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _function_source(name: str) -> str:
|
||||||
|
"""Return one top-level JS function using balanced braces."""
|
||||||
|
text = _source()
|
||||||
|
markers = (f"function {name}", f"async function {name}", f"export function {name}", f"export async function {name}")
|
||||||
|
starts = [text.find(marker) for marker in markers]
|
||||||
|
starts = [start for start in starts if start >= 0]
|
||||||
|
assert starts, f"missing function {name}"
|
||||||
|
start = min(starts)
|
||||||
|
paren = text.index("(", start)
|
||||||
|
paren_depth = 0
|
||||||
|
quote = None
|
||||||
|
escaped = False
|
||||||
|
for index in range(paren, len(text)):
|
||||||
|
char = text[index]
|
||||||
|
if quote:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == quote:
|
||||||
|
quote = None
|
||||||
|
continue
|
||||||
|
if char in ("'", '"', "`"):
|
||||||
|
quote = char
|
||||||
|
elif char == "(":
|
||||||
|
paren_depth += 1
|
||||||
|
elif char == ")":
|
||||||
|
paren_depth -= 1
|
||||||
|
if paren_depth == 0:
|
||||||
|
brace = text.index("{", index)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"unterminated signature {name}")
|
||||||
|
depth = 0
|
||||||
|
quote = None
|
||||||
|
escaped = False
|
||||||
|
template_depth = 0
|
||||||
|
for index in range(brace, len(text)):
|
||||||
|
char = text[index]
|
||||||
|
if quote:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == quote and template_depth == 0:
|
||||||
|
quote = None
|
||||||
|
elif quote == "`" and char == "$" and index + 1 < len(text) and text[index + 1] == "{":
|
||||||
|
template_depth += 1
|
||||||
|
elif quote == "`" and char == "}" and template_depth:
|
||||||
|
template_depth -= 1
|
||||||
|
continue
|
||||||
|
if char in ("'", '"', "`"):
|
||||||
|
quote = char
|
||||||
|
elif char == "{":
|
||||||
|
depth += 1
|
||||||
|
elif char == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return text[start:index + 1]
|
||||||
|
raise AssertionError(f"unterminated function {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scheduler_scenario(scenario: str):
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node not on PATH")
|
||||||
|
functions = "\n".join(
|
||||||
|
_function_source(name)
|
||||||
|
for name in (
|
||||||
|
"_isChatInteractionBusy",
|
||||||
|
"_canRunEmailPrewarm",
|
||||||
|
"_isEmailPrewarmTemporarilyBlocked",
|
||||||
|
"_settleEmailPrewarm",
|
||||||
|
"_cancelEmailPrewarm",
|
||||||
|
"_scheduleEmailPrewarm",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
script = f"""
|
||||||
|
let now = 0;
|
||||||
|
Date.now = () => now;
|
||||||
|
const state = {{ _libOpen: false, _libLoading: false }};
|
||||||
|
let _libSearchInFlight = false;
|
||||||
|
let _libPrewarmDelayTimer = null;
|
||||||
|
let _libPrewarmIdleHandle = null;
|
||||||
|
let _libPrewarmPromise = null;
|
||||||
|
let _libPrewarmResolve = null;
|
||||||
|
let _libPrewarmAbortController = null;
|
||||||
|
let _libPrewarmDetachPriorityListeners = null;
|
||||||
|
let _libPrewarmGeneration = 0;
|
||||||
|
let nextHandle = 1;
|
||||||
|
const timers = new Map();
|
||||||
|
const idleCallbacks = new Map();
|
||||||
|
let idleRequestCount = 0;
|
||||||
|
function eventTarget(target) {{
|
||||||
|
const listeners = new Map();
|
||||||
|
target.addEventListener = (type, callback) => {{
|
||||||
|
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||||
|
listeners.get(type).add(callback);
|
||||||
|
}};
|
||||||
|
target.removeEventListener = (type, callback) => listeners.get(type)?.delete(callback);
|
||||||
|
target.dispatchEvent = (event) => {{
|
||||||
|
for (const callback of [...(listeners.get(event.type) || [])]) callback(event);
|
||||||
|
}};
|
||||||
|
target.listenerCount = (type) => listeners.get(type)?.size || 0;
|
||||||
|
return target;
|
||||||
|
}}
|
||||||
|
const document = eventTarget({{ visibilityState: 'visible' }});
|
||||||
|
const window = {{
|
||||||
|
__odysseusChatBusy: false,
|
||||||
|
__odysseusChatBusyUntil: 0,
|
||||||
|
requestIdleCallback(callback) {{
|
||||||
|
const handle = nextHandle++;
|
||||||
|
idleRequestCount += 1;
|
||||||
|
idleCallbacks.set(handle, callback);
|
||||||
|
return handle;
|
||||||
|
}},
|
||||||
|
cancelIdleCallback(handle) {{ idleCallbacks.delete(handle); }},
|
||||||
|
}};
|
||||||
|
eventTarget(window);
|
||||||
|
function setTimeout(callback, delay) {{
|
||||||
|
const handle = nextHandle++;
|
||||||
|
timers.set(handle, {{ callback, at: now + Number(delay || 0) }});
|
||||||
|
return handle;
|
||||||
|
}}
|
||||||
|
function clearTimeout(handle) {{ timers.delete(handle); }}
|
||||||
|
async function flushMicrotasks() {{
|
||||||
|
for (let i = 0; i < 6; i += 1) await Promise.resolve();
|
||||||
|
}}
|
||||||
|
async function advanceTo(target) {{
|
||||||
|
while (true) {{
|
||||||
|
const pending = [...timers.entries()]
|
||||||
|
.filter(([, timer]) => timer.at <= target)
|
||||||
|
.sort((a, b) => a[1].at - b[1].at)[0];
|
||||||
|
if (!pending) break;
|
||||||
|
const [handle, timer] = pending;
|
||||||
|
timers.delete(handle);
|
||||||
|
now = timer.at;
|
||||||
|
timer.callback();
|
||||||
|
await flushMicrotasks();
|
||||||
|
}}
|
||||||
|
now = target;
|
||||||
|
await flushMicrotasks();
|
||||||
|
}}
|
||||||
|
async function fireNextIdle(budget = 5) {{
|
||||||
|
const pending = idleCallbacks.entries().next().value;
|
||||||
|
if (!pending) throw new Error('no idle callback pending');
|
||||||
|
const [handle, callback] = pending;
|
||||||
|
idleCallbacks.delete(handle);
|
||||||
|
callback({{ didTimeout: false, timeRemaining: () => budget }});
|
||||||
|
await flushMicrotasks();
|
||||||
|
}}
|
||||||
|
{functions}
|
||||||
|
{scenario}
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
[node, "--input-type=module"],
|
||||||
|
input=script,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_is_genuine_idle_only_and_single_flight():
|
||||||
|
scheduler = _function_source("_scheduleEmailPrewarm")
|
||||||
|
|
||||||
|
assert "if (_libPrewarmPromise) return _libPrewarmPromise;" in scheduler
|
||||||
|
assert "typeof window.requestIdleCallback !== 'function'" in scheduler
|
||||||
|
assert "return Promise.resolve(false);" in scheduler
|
||||||
|
assert "window.requestIdleCallback((deadline)" in scheduler
|
||||||
|
assert "!deadline.didTimeout" in scheduler
|
||||||
|
assert "deadline.timeRemaining() > 0" in scheduler
|
||||||
|
|
||||||
|
idle_callback = scheduler.index("window.requestIdleCallback((deadline)")
|
||||||
|
assert "Promise.resolve()" in scheduler
|
||||||
|
task_start = scheduler.index("task({ signal: controller.signal, generation })")
|
||||||
|
assert idle_callback < task_start, "network work must only be reachable from the idle callback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_temporary_chat_priority_retries_one_single_flight_until_idle():
|
||||||
|
out = _run_scheduler_scenario("""
|
||||||
|
window.__odysseusChatBusyUntil = 10000;
|
||||||
|
let taskCalls = 0;
|
||||||
|
const task = async () => { taskCalls += 1; return true; };
|
||||||
|
const first = _scheduleEmailPrewarm(task, { delay: 1800 });
|
||||||
|
const joined = _scheduleEmailPrewarm(task, { delay: 0 });
|
||||||
|
const samePromise = first === joined;
|
||||||
|
await advanceTo(1800);
|
||||||
|
await fireNextIdle(7);
|
||||||
|
const callsWhileBusy = taskCalls;
|
||||||
|
while (now < 10300) {
|
||||||
|
await advanceTo(now + 500);
|
||||||
|
await fireNextIdle(7);
|
||||||
|
}
|
||||||
|
const result = await first;
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
result, samePromise, callsWhileBusy, taskCalls, idleRequestCount,
|
||||||
|
timers: timers.size, idleCallbacks: idleCallbacks.size,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
assert out == {
|
||||||
|
"result": True,
|
||||||
|
"samePromise": True,
|
||||||
|
"callsWhileBusy": 0,
|
||||||
|
"taskCalls": 1,
|
||||||
|
"idleRequestCount": 18,
|
||||||
|
"timers": 0,
|
||||||
|
"idleCallbacks": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_prewarm_cannot_issue_a_delayed_duplicate():
|
||||||
|
out = _run_scheduler_scenario("""
|
||||||
|
let taskCalls = 0;
|
||||||
|
const pending = _scheduleEmailPrewarm(async () => { taskCalls += 1; return true; }, { delay: 1800 });
|
||||||
|
await advanceTo(1400);
|
||||||
|
_cancelEmailPrewarm();
|
||||||
|
await advanceTo(12000);
|
||||||
|
const result = await pending;
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
result, taskCalls, idleRequestCount,
|
||||||
|
timers: timers.size, idleCallbacks: idleCallbacks.size,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
assert out == {
|
||||||
|
"result": False,
|
||||||
|
"taskCalls": 0,
|
||||||
|
"idleRequestCount": 0,
|
||||||
|
"timers": 0,
|
||||||
|
"idleCallbacks": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("transition", ["busy", "hidden"])
|
||||||
|
def test_active_prewarm_is_aborted_and_retried_once_after_priority_transition(transition):
|
||||||
|
block = (
|
||||||
|
"window.__odysseusChatBusy = true; "
|
||||||
|
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
|
||||||
|
if transition == "busy"
|
||||||
|
else "document.visibilityState = 'hidden'; document.dispatchEvent({ type: 'visibilitychange' });"
|
||||||
|
)
|
||||||
|
unblock = (
|
||||||
|
"window.__odysseusChatBusy = false; window.__odysseusChatBusyUntil = now; "
|
||||||
|
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
|
||||||
|
if transition == "busy"
|
||||||
|
else "document.visibilityState = 'visible'; document.dispatchEvent({ type: 'visibilitychange' });"
|
||||||
|
)
|
||||||
|
out = _run_scheduler_scenario(f"""
|
||||||
|
let taskCalls = 0;
|
||||||
|
let firstSignal = null;
|
||||||
|
let finishFirst;
|
||||||
|
const firstAttempt = new Promise(resolve => {{ finishFirst = resolve; }});
|
||||||
|
const pending = _scheduleEmailPrewarm(async ({{ signal }}) => {{
|
||||||
|
taskCalls += 1;
|
||||||
|
if (taskCalls === 1) {{ firstSignal = signal; return firstAttempt; }}
|
||||||
|
return true;
|
||||||
|
}});
|
||||||
|
await fireNextIdle(7);
|
||||||
|
{block}
|
||||||
|
const aborted = firstSignal.aborted;
|
||||||
|
{unblock}
|
||||||
|
const callsBeforeLateResult = taskCalls;
|
||||||
|
finishFirst(true);
|
||||||
|
await flushMicrotasks();
|
||||||
|
const stillPendingAfterLateResult = _libPrewarmPromise === pending;
|
||||||
|
await advanceTo(now + 500);
|
||||||
|
await fireNextIdle(7);
|
||||||
|
const result = await pending;
|
||||||
|
console.log(JSON.stringify({{
|
||||||
|
result, aborted, callsBeforeLateResult, taskCalls,
|
||||||
|
stillPendingAfterLateResult,
|
||||||
|
timers: timers.size, idleCallbacks: idleCallbacks.size,
|
||||||
|
chatListeners: window.listenerCount('odysseus:chat-busy-change'),
|
||||||
|
visibilityListeners: document.listenerCount('visibilitychange'),
|
||||||
|
}}));
|
||||||
|
""")
|
||||||
|
assert out == {
|
||||||
|
"result": True,
|
||||||
|
"aborted": True,
|
||||||
|
"callsBeforeLateResult": 1,
|
||||||
|
"taskCalls": 2,
|
||||||
|
"stillPendingAfterLateResult": True,
|
||||||
|
"timers": 0,
|
||||||
|
"idleCallbacks": 0,
|
||||||
|
"chatListeners": 0,
|
||||||
|
"visibilityListeners": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_skips_hidden_and_foreground_work():
|
||||||
|
guard = _function_source("_canRunEmailPrewarm")
|
||||||
|
|
||||||
|
assert "state._libOpen" in guard
|
||||||
|
assert "state._libLoading" in guard
|
||||||
|
assert "_libSearchInFlight" in guard
|
||||||
|
assert "document.visibilityState !== 'visible'" in guard
|
||||||
|
assert "!_isChatInteractionBusy()" in guard
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_selects_only_last_used_or_default_account():
|
||||||
|
chooser = _function_source("_chooseEmailPrewarmAccountId")
|
||||||
|
prewarm = _function_source("_prewarmEmailViews")
|
||||||
|
|
||||||
|
assert "_rememberedEmailAccountId()" in chooser
|
||||||
|
assert "a.enabled !== false" in chooser
|
||||||
|
assert "a.is_default" in chooser
|
||||||
|
assert "enabled[0]" in chooser
|
||||||
|
|
||||||
|
assert "for (" not in prewarm
|
||||||
|
assert "orderedAccountIds" not in prewarm
|
||||||
|
assert "slice(0, 4)" not in prewarm
|
||||||
|
assert "/api/email/folders" not in prewarm
|
||||||
|
assert "/api/email/unread-state" not in prewarm
|
||||||
|
assert prewarm.count("/api/email/list") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_account_chooser_rejects_disabled_or_empty_authoritative_inventory():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node not on PATH")
|
||||||
|
chooser = _function_source("_chooseEmailPrewarmAccountId")
|
||||||
|
script = f"""
|
||||||
|
const state = {{ _libAccountId: 'disabled-current' }};
|
||||||
|
function _rememberedEmailAccountId() {{ return 'disabled-remembered'; }}
|
||||||
|
{chooser}
|
||||||
|
const onlyDisabled = _chooseEmailPrewarmAccountId([
|
||||||
|
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
|
||||||
|
{{ id: 'disabled-current', enabled: false }},
|
||||||
|
]);
|
||||||
|
const empty = _chooseEmailPrewarmAccountId([]);
|
||||||
|
const mixed = _chooseEmailPrewarmAccountId([
|
||||||
|
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
|
||||||
|
{{ id: 'enabled-default', enabled: true, is_default: true }},
|
||||||
|
]);
|
||||||
|
console.log(JSON.stringify({{ onlyDisabled, empty, mixed }}));
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
[node, "--input-type=module"],
|
||||||
|
input=script,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
assert json.loads(proc.stdout.strip()) == {
|
||||||
|
"onlyDisabled": "",
|
||||||
|
"empty": "",
|
||||||
|
"mixed": "enabled-default",
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_accounts = _function_source("_ensureEmailAccountsForPrewarm")
|
||||||
|
assert "if (!accountId) return null;" in ensure_accounts
|
||||||
|
assert ensure_accounts.index("if (!accountId) return null;") < ensure_accounts.index("_publishActiveAccount();")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_is_bounded_to_the_interactive_initial_page_size():
|
||||||
|
text = _source()
|
||||||
|
prewarm = _function_source("_prewarmEmailViews")
|
||||||
|
|
||||||
|
assert "const _LIB_INITIAL_PAGE_SIZE = 100;" in text
|
||||||
|
assert "limit: _LIB_INITIAL_PAGE_SIZE" in prewarm
|
||||||
|
assert text.count("limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}") == 2
|
||||||
|
assert "limit: 100" not in prewarm
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_cancels_scheduled_or_inflight_prewarm_first():
|
||||||
|
text = _source()
|
||||||
|
cancel = _function_source("_cancelEmailPrewarm")
|
||||||
|
open_library = _function_source("openEmailLibrary")
|
||||||
|
|
||||||
|
assert "clearTimeout(_libPrewarmDelayTimer)" in cancel
|
||||||
|
assert "window.cancelIdleCallback(_libPrewarmIdleHandle)" in cancel
|
||||||
|
assert "_libPrewarmAbortController?.abort()" in cancel
|
||||||
|
assert "_libPrewarmGeneration += 1" in cancel
|
||||||
|
assert open_library.index("_cancelEmailPrewarm();") < open_library.index("state._libOpen = true;")
|
||||||
|
assert "_loadEmailsWhenChatIdle" not in text
|
||||||
|
assert text.count("_loadEmails({ useCache: true });") >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_cancels_pending_prewarm_cleanup():
|
||||||
|
close_library = _function_source("closeEmailLibrary")
|
||||||
|
|
||||||
|
assert close_library.index("_cancelEmailPrewarm();") < close_library.index("state._libOpen = false;")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unread_warm_joins_the_same_idle_single_flight_gate():
|
||||||
|
unread_entry = _function_source("prewarmUnreadEmails")
|
||||||
|
unread_work = _function_source("_prewarmUnreadEmailsNow")
|
||||||
|
|
||||||
|
assert "_scheduleEmailPrewarm(" in unread_entry
|
||||||
|
assert "fetch(" not in unread_entry
|
||||||
|
assert "_ensureEmailAccountsForPrewarm({ signal, generation })" in unread_work
|
||||||
|
assert "signal" in unread_work
|
||||||
|
assert "Math.min(20" in unread_work
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Focused browser-side regression coverage for authoritative email opens."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_INBOX_JS = _REPO / "static" / "js" / "emailInbox.js"
|
||||||
|
_LIBRARY_JS = _REPO / "static" / "js" / "emailLibrary.js"
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_between(source: str, signature: str, next_marker: str) -> str:
|
||||||
|
start = source.index(signature)
|
||||||
|
end = source.index(next_marker, start)
|
||||||
|
return source[start:end].rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_unread_preview_has_one_authoritative_request_and_rollback():
|
||||||
|
source = _LIBRARY_JS.read_text(encoding="utf-8")
|
||||||
|
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
||||||
|
|
||||||
|
assert function.count("/api/email/read/") == 1
|
||||||
|
assert "/api/email/mark-read/" not in function
|
||||||
|
assert "&mark_seen=true" in function
|
||||||
|
assert "_syncEmailReadState(uidAtStart, true, readContext)" in function
|
||||||
|
assert "_syncEmailReadState(uidAtStart, false, readContext)" in function
|
||||||
|
assert "openGeneration === _emailCardOpenSeq" in function
|
||||||
|
assert "_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation" in function
|
||||||
|
assert "authoritativeReadSucceeded = true;" in function
|
||||||
|
assert "if (!authoritativeReadSucceeded) restoreUnreadState();" in function
|
||||||
|
assert "if (!isCurrentOpen()) return" in function
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_library_authoritative_success_defeats_newer_rollback_in_either_order():
|
||||||
|
source = _LIBRARY_JS.read_text(encoding="utf-8")
|
||||||
|
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
||||||
|
settlements = _extract_between(
|
||||||
|
function,
|
||||||
|
" const restoreUnreadState = () => {",
|
||||||
|
"\n\n // Collapse any other expanded card",
|
||||||
|
)
|
||||||
|
|
||||||
|
harness = f"""
|
||||||
|
const _emailReadMutations = new Map();
|
||||||
|
const readContextKey = 'same-mailbox-message';
|
||||||
|
const uidAtStart = '1';
|
||||||
|
const readContext = {{ accountId: 'acct-a', folder: 'INBOX', uid: '1' }};
|
||||||
|
const readUpdates = [];
|
||||||
|
function _syncEmailReadState(uid, isRead, context) {{
|
||||||
|
readUpdates.push({{ uid, isRead, context }});
|
||||||
|
}}
|
||||||
|
function createSettlers(readMutation) {{
|
||||||
|
{settlements}
|
||||||
|
return {{ restoreUnreadState, commitReadState }};
|
||||||
|
}}
|
||||||
|
function runRace(successFirst) {{
|
||||||
|
_emailReadMutations.clear();
|
||||||
|
readUpdates.length = 0;
|
||||||
|
const mutationA = {{ generation: 1, rollbackUnread: true }};
|
||||||
|
_emailReadMutations.set(readContextKey, mutationA);
|
||||||
|
const settlersA = createSettlers(mutationA);
|
||||||
|
const mutationB = {{ generation: 2, rollbackUnread: true }};
|
||||||
|
_emailReadMutations.set(readContextKey, mutationB);
|
||||||
|
const settlersB = createSettlers(mutationB);
|
||||||
|
if (successFirst) {{
|
||||||
|
settlersA.commitReadState();
|
||||||
|
settlersB.restoreUnreadState();
|
||||||
|
}} else {{
|
||||||
|
settlersB.restoreUnreadState();
|
||||||
|
settlersA.commitReadState();
|
||||||
|
}}
|
||||||
|
return {{
|
||||||
|
hasMutation: _emailReadMutations.has(readContextKey),
|
||||||
|
readUpdates: readUpdates.map(update => update.isRead),
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
console.log(JSON.stringify({{
|
||||||
|
successFirst: runRace(true),
|
||||||
|
failureFirst: runRace(false),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=harness,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
|
||||||
|
assert json.loads(proc.stdout.strip()) == {
|
||||||
|
"successFirst": {"hasMutation": False, "readUpdates": [True]},
|
||||||
|
"failureFirst": {"hasMutation": False, "readUpdates": [False, True]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_reply_open_carries_immutable_mailbox_context():
|
||||||
|
library_source = _LIBRARY_JS.read_text(encoding="utf-8")
|
||||||
|
inbox_source = _INBOX_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "const mailboxGeneration = _emailMailboxGeneration;" in library_source
|
||||||
|
assert "messageFolder = String(options.email?.folder || libraryFolder)" in library_source
|
||||||
|
assert "return onEmailClick({ ...options, mailboxContext });" in library_source
|
||||||
|
assert "mailboxContext?.messageFolder || _currentFolder" in inbox_source
|
||||||
|
assert "mailboxContextIsCurrent()" in inbox_source
|
||||||
|
assert "if (!isCurrentOpen()) return;\n let activeSid = await _createEmailChat" in inbox_source
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_inbox_late_read_response_cannot_apply_after_newer_open():
|
||||||
|
source = _INBOX_JS.read_text(encoding="utf-8")
|
||||||
|
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
|
||||||
|
assert "let _openEmailRequestSeq = 0;" in source
|
||||||
|
|
||||||
|
harness = f"""
|
||||||
|
const realLog = console.log;
|
||||||
|
console.error = () => {{}};
|
||||||
|
const API_BASE = 'https://odysseus.invalid';
|
||||||
|
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
|
||||||
|
let _currentFolder = 'INBOX';
|
||||||
|
const _acct = () => '&account_id=acct-a';
|
||||||
|
let _openEmailRequestSeq = 0;
|
||||||
|
let _docModule = null;
|
||||||
|
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
|
||||||
|
const sessionModule = null;
|
||||||
|
let firstResolve;
|
||||||
|
const calls = [];
|
||||||
|
async function fetch(url) {{
|
||||||
|
calls.push(String(url));
|
||||||
|
if (calls.length === 1) {{
|
||||||
|
return await new Promise((resolve) => {{
|
||||||
|
firstResolve = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
return {{ json: async () => ({{ error: 'newer open completed test' }}) }};
|
||||||
|
}}
|
||||||
|
{function}
|
||||||
|
const oldEmail = {{ uid: '1', is_read: false }};
|
||||||
|
const newerEmail = {{ uid: '2', is_read: false }};
|
||||||
|
const first = _openEmail(oldEmail, null);
|
||||||
|
await Promise.resolve();
|
||||||
|
const second = _openEmail(newerEmail, null);
|
||||||
|
await second;
|
||||||
|
firstResolve();
|
||||||
|
await first;
|
||||||
|
realLog(JSON.stringify({{ calls, oldRead: oldEmail.is_read, newerRead: newerEmail.is_read }}));
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=harness,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
|
||||||
|
result = json.loads(proc.stdout.strip())
|
||||||
|
assert len(result["calls"]) == 2
|
||||||
|
assert all("mark_seen=true" in url for url in result["calls"])
|
||||||
|
assert result["oldRead"] is False
|
||||||
|
assert result["newerRead"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
@pytest.mark.parametrize("context_change", ["account", "folder", "library"])
|
||||||
|
def test_inbox_late_read_response_cannot_apply_after_mailbox_switch(context_change):
|
||||||
|
source = _INBOX_JS.read_text(encoding="utf-8")
|
||||||
|
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
|
||||||
|
|
||||||
|
changes = {
|
||||||
|
"account": "window.__odysseusActiveEmailAccount = 'acct-b';",
|
||||||
|
"folder": "_currentFolder = 'Archive';",
|
||||||
|
"library": "libraryCurrent = false;",
|
||||||
|
}
|
||||||
|
change = changes[context_change]
|
||||||
|
open_call = (
|
||||||
|
"_openEmail(email, null, null, 'reply', '', '', mailboxContext)"
|
||||||
|
if context_change == "library"
|
||||||
|
else "_openEmail(email, null)"
|
||||||
|
)
|
||||||
|
harness = f"""
|
||||||
|
const realLog = console.log;
|
||||||
|
console.error = () => {{}};
|
||||||
|
const API_BASE = 'https://odysseus.invalid';
|
||||||
|
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
|
||||||
|
let _currentFolder = 'INBOX';
|
||||||
|
const _acct = () => '&account_id=acct-a';
|
||||||
|
let _openEmailRequestSeq = 0;
|
||||||
|
let libraryCurrent = true;
|
||||||
|
const mailboxContext = {{
|
||||||
|
accountId: 'acct-a',
|
||||||
|
messageFolder: 'Archive',
|
||||||
|
isCurrent: () => libraryCurrent,
|
||||||
|
}};
|
||||||
|
let createCalls = 0;
|
||||||
|
let _docModule = {{}};
|
||||||
|
async function _createEmailChat() {{ createCalls += 1; return 'stale-session'; }}
|
||||||
|
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
|
||||||
|
const sessionModule = null;
|
||||||
|
let resolveRead;
|
||||||
|
async function fetch() {{
|
||||||
|
return await new Promise((resolve) => {{
|
||||||
|
resolveRead = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
{function}
|
||||||
|
const email = {{ uid: '1', is_read: false }};
|
||||||
|
const pending = {open_call};
|
||||||
|
await Promise.resolve();
|
||||||
|
{change}
|
||||||
|
resolveRead();
|
||||||
|
await pending;
|
||||||
|
realLog(JSON.stringify({{ createCalls, isRead: email.is_read }}));
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=harness,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
|
||||||
|
result = json.loads(proc.stdout.strip())
|
||||||
|
assert result == {"createCalls": 0, "isRead": False}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
RAW_EMAIL = (
|
||||||
|
b"From: Sender <sender@example.com>\r\n"
|
||||||
|
b"To: Alice <alice@example.com>\r\n"
|
||||||
|
b"Subject: Single authoritative open\r\n"
|
||||||
|
b"Message-ID: <single-open@example.com>\r\n"
|
||||||
|
b"Date: Tue, 04 Aug 2026 12:00:00 +0000\r\n"
|
||||||
|
b"Content-Type: text/plain; charset=utf-8\r\n"
|
||||||
|
b"\r\n"
|
||||||
|
b"Body"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_endpoint(router, path: str, method: str):
|
||||||
|
method = method.upper()
|
||||||
|
for route in router.routes:
|
||||||
|
if route.path == path and method in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"route not found: {method} {path}")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeImap:
|
||||||
|
def __init__(self, store_status="OK", readonly_mailbox=False):
|
||||||
|
self.store_status = store_status
|
||||||
|
# Shared archives and some provider folders reject a read-write SELECT.
|
||||||
|
self.readonly_mailbox = readonly_mailbox
|
||||||
|
self.selects = []
|
||||||
|
self.commands = []
|
||||||
|
|
||||||
|
def select(self, mailbox, readonly=False):
|
||||||
|
self.selects.append((mailbox, readonly))
|
||||||
|
if self.readonly_mailbox and not readonly:
|
||||||
|
raise OSError("[READ-ONLY] Mailbox is read-only")
|
||||||
|
return "OK", [b"1"]
|
||||||
|
|
||||||
|
def uid(self, command, uid, *args):
|
||||||
|
self.commands.append((command, uid, *args))
|
||||||
|
if command == "FETCH":
|
||||||
|
header, body = RAW_EMAIL.split(b"\r\n\r\n", 1)
|
||||||
|
return "OK", [
|
||||||
|
(b"1 (UID 42 BODY[HEADER])", header + b"\r\n\r\n"),
|
||||||
|
(b"1 (UID 42 BODY[TEXT]<0>)", body),
|
||||||
|
]
|
||||||
|
if command == "STORE":
|
||||||
|
# RFC 3501 STORE takes a parenthesized flag-list. GreenMail rejects
|
||||||
|
# the formerly emitted bare ``\Seen`` atom with BAD, so keep the
|
||||||
|
# fake strict enough to catch that provider-compatibility failure.
|
||||||
|
if args != ("+FLAGS", "(\\Seen)"):
|
||||||
|
return "BAD", [b"Expected:'(' found:'\\'"]
|
||||||
|
return self.store_status, []
|
||||||
|
raise AssertionError(f"unexpected IMAP command: {command}")
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fakes(monkeypatch, tmp_path, *, store_status="OK", readonly_mailbox=False):
|
||||||
|
import routes.email_helpers as email_helpers
|
||||||
|
import routes.email_routes as email_routes
|
||||||
|
|
||||||
|
db_path = tmp_path / "email.db"
|
||||||
|
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
||||||
|
monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
|
||||||
|
email_helpers._init_scheduled_db()
|
||||||
|
|
||||||
|
connections = []
|
||||||
|
indexed_updates = []
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fake_imap(account_id=None, owner=""):
|
||||||
|
conn = FakeImap(store_status=store_status, readonly_mailbox=readonly_mailbox)
|
||||||
|
connections.append(conn)
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
monkeypatch.setattr(email_routes, "_start_poller", lambda: None)
|
||||||
|
monkeypatch.setattr(email_routes, "_imap", fake_imap)
|
||||||
|
monkeypatch.setattr(email_routes, "_email_preview_cache_get", lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(email_routes, "_email_preview_cache_put", lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(email_routes, "_email_attachment_meta_cache_get", lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(email_routes, "_email_attachment_meta_cache_put", lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
email_routes,
|
||||||
|
"_email_index_update_flags",
|
||||||
|
lambda *args, **_kwargs: indexed_updates.append(args),
|
||||||
|
)
|
||||||
|
return email_routes, connections, indexed_updates
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("mark_seen", [True, False])
|
||||||
|
async def test_read_email_seen_contract_uses_one_imap_connection(monkeypatch, tmp_path, mark_seen):
|
||||||
|
email_routes, connections, indexed_updates = _install_fakes(monkeypatch, tmp_path)
|
||||||
|
router = email_routes.setup_email_routes()
|
||||||
|
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
||||||
|
|
||||||
|
result = await read_email(
|
||||||
|
"42",
|
||||||
|
folder="INBOX",
|
||||||
|
account_id="acct-a",
|
||||||
|
mark_seen=mark_seen,
|
||||||
|
full=False,
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["uid"] == "42"
|
||||||
|
assert len(connections) == 1
|
||||||
|
conn = connections[0]
|
||||||
|
assert conn.selects == [(conn.selects[0][0], not mark_seen)]
|
||||||
|
assert [command[0] for command in conn.commands] == (
|
||||||
|
["FETCH", "STORE"] if mark_seen else ["FETCH"]
|
||||||
|
)
|
||||||
|
assert "BODY.PEEK[HEADER]" in conn.commands[0][2]
|
||||||
|
if mark_seen:
|
||||||
|
assert conn.commands[1][2:] == ("+FLAGS", "(\\Seen)")
|
||||||
|
assert indexed_updates == [("alice", "acct-a", "INBOX", "42", "\\Seen", True)]
|
||||||
|
else:
|
||||||
|
assert indexed_updates == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cached_read_awaits_one_seen_store_without_refetch(monkeypatch, tmp_path):
|
||||||
|
email_routes, connections, indexed_updates = _install_fakes(monkeypatch, tmp_path)
|
||||||
|
router = email_routes.setup_email_routes()
|
||||||
|
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
||||||
|
|
||||||
|
first = await read_email(
|
||||||
|
"42", folder="INBOX", account_id="acct-a", mark_seen=False, full=False, owner="alice"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
asyncio,
|
||||||
|
"create_task",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||||
|
AssertionError("cached mark-seen must be awaited, not scheduled")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
second = await read_email(
|
||||||
|
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first["message_id"] == second["message_id"]
|
||||||
|
assert len(connections) == 2
|
||||||
|
assert [command[0] for command in connections[0].commands] == ["FETCH"]
|
||||||
|
assert [command[0] for command in connections[1].commands] == ["STORE"]
|
||||||
|
assert connections[1].commands[0][2:] == ("+FLAGS", "(\\Seen)")
|
||||||
|
assert connections[1].selects[0][1] is False
|
||||||
|
assert indexed_updates == [("alice", "acct-a", "INBOX", "42", "\\Seen", True)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seen_store_failure_returns_the_body_and_reports_the_failure(monkeypatch, tmp_path):
|
||||||
|
"""A failed STORE must not cost the reader the message.
|
||||||
|
|
||||||
|
The body was fetched successfully before the flag update was attempted, so
|
||||||
|
the response stays a normal read and carries `mark_seen_failed` for the
|
||||||
|
client to roll its optimistic unread marker back.
|
||||||
|
"""
|
||||||
|
email_routes, connections, indexed_updates = _install_fakes(
|
||||||
|
monkeypatch, tmp_path, store_status="NO"
|
||||||
|
)
|
||||||
|
router = email_routes.setup_email_routes()
|
||||||
|
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
||||||
|
|
||||||
|
result = await read_email(
|
||||||
|
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["uid"] == "42"
|
||||||
|
assert result["mark_seen_failed"] is True
|
||||||
|
assert len(connections) == 1
|
||||||
|
assert [command[0] for command in connections[0].commands] == ["FETCH", "STORE"]
|
||||||
|
# The local index must not claim a transition the provider rejected.
|
||||||
|
assert indexed_updates == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_only_mailbox_serves_the_message_without_marking_seen(monkeypatch, tmp_path):
|
||||||
|
"""A mailbox that refuses a read-write SELECT is still readable.
|
||||||
|
|
||||||
|
Opening the message is the user's actual goal; the \\Seen transition is a
|
||||||
|
side effect of it. A folder that cannot accept flag changes must therefore
|
||||||
|
fall back to a read-only selection rather than failing the open.
|
||||||
|
"""
|
||||||
|
email_routes, connections, indexed_updates = _install_fakes(
|
||||||
|
monkeypatch, tmp_path, readonly_mailbox=True
|
||||||
|
)
|
||||||
|
router = email_routes.setup_email_routes()
|
||||||
|
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
||||||
|
|
||||||
|
result = await read_email(
|
||||||
|
"42", folder="Archive", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["uid"] == "42"
|
||||||
|
assert result["mark_seen_failed"] is True
|
||||||
|
# Read-write attempt first, then the read-only retry on the same connection.
|
||||||
|
assert [readonly for _mailbox, readonly in connections[0].selects] == [False, True]
|
||||||
|
# No STORE is attempted once the mailbox is known to be read-only.
|
||||||
|
assert [command[0] for command in connections[0].commands] == ["FETCH"]
|
||||||
|
assert indexed_updates == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_seen_state_is_not_replayed_from_cache(monkeypatch, tmp_path):
|
||||||
|
"""`mark_seen_failed` describes one request, not the stored message.
|
||||||
|
|
||||||
|
A second read that does not ask to mark seen must come back clean, or every
|
||||||
|
later reader would inherit a STORE failure it never issued.
|
||||||
|
"""
|
||||||
|
email_routes, connections, _ = _install_fakes(monkeypatch, tmp_path, store_status="NO")
|
||||||
|
router = email_routes.setup_email_routes()
|
||||||
|
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
||||||
|
|
||||||
|
failed = await read_email(
|
||||||
|
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
||||||
|
)
|
||||||
|
replayed = await read_email(
|
||||||
|
"42", folder="INBOX", account_id="acct-a", mark_seen=False, full=False, owner="alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert failed["mark_seen_failed"] is True
|
||||||
|
assert replayed.get("mark_seen_failed", False) is False
|
||||||
|
assert replayed["uid"] == "42"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unparseable_read_does_not_mark_seen(monkeypatch, tmp_path):
|
||||||
|
email_routes, connections, indexed_updates = _install_fakes(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
email_routes.email_mod,
|
||||||
|
"message_from_bytes",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("malformed message")),
|
||||||
|
)
|
||||||
|
router = email_routes.setup_email_routes()
|
||||||
|
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
||||||
|
|
||||||
|
result = await read_email(
|
||||||
|
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"error": "Mail operation failed"}
|
||||||
|
assert len(connections) == 1
|
||||||
|
assert [command[0] for command in connections[0].commands] == ["FETCH"]
|
||||||
|
assert indexed_updates == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cached_seen_store_failure_returns_the_cached_body(monkeypatch, tmp_path):
|
||||||
|
"""A cache hit already holds a complete message; a failed STORE cannot take it away.
|
||||||
|
|
||||||
|
This is the path where withholding the body would be least defensible — the
|
||||||
|
response is served from memory and needed no network at all.
|
||||||
|
"""
|
||||||
|
email_routes, connections, indexed_updates = _install_fakes(
|
||||||
|
monkeypatch, tmp_path, store_status="NO"
|
||||||
|
)
|
||||||
|
router = email_routes.setup_email_routes()
|
||||||
|
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
||||||
|
|
||||||
|
first = await read_email(
|
||||||
|
"42", folder="INBOX", account_id="acct-a", mark_seen=False, full=False, owner="alice"
|
||||||
|
)
|
||||||
|
second = await read_email(
|
||||||
|
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first["uid"] == "42"
|
||||||
|
assert "error" not in second
|
||||||
|
assert second["uid"] == "42"
|
||||||
|
assert second["body"] == first["body"]
|
||||||
|
assert second["mark_seen_failed"] is True
|
||||||
|
assert len(connections) == 2
|
||||||
|
assert [command[0] for command in connections[0].commands] == ["FETCH"]
|
||||||
|
assert [command[0] for command in connections[1].commands] == ["STORE"]
|
||||||
|
assert indexed_updates == []
|
||||||
@@ -34,6 +34,11 @@ class _FakeSessionManager:
|
|||||||
self.sessions = {"src-id": source}
|
self.sessions = {"src-id": source}
|
||||||
self.created = None
|
self.created = None
|
||||||
|
|
||||||
|
def get_session(self, session_id):
|
||||||
|
# Fork looks the source up through get_session — the hydration seam —
|
||||||
|
# so a session only present in the DB still forks a real transcript.
|
||||||
|
return self.sessions[session_id]
|
||||||
|
|
||||||
def create_session(self, session_id=None, name=None, endpoint_url=None,
|
def create_session(self, session_id=None, name=None, endpoint_url=None,
|
||||||
model=None, rag=False, owner=None):
|
model=None, rag=False, owner=None):
|
||||||
self.created = _FakeSession(name=name, owner=owner)
|
self.created = _FakeSession(name=name, owner=owner)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ The in-memory branch skips messages whose metadata has ``hidden`` (e.g.
|
|||||||
compaction summaries that are kept for AI context but not shown to the user).
|
compaction summaries that are kept for AI context but not shown to the user).
|
||||||
The DB fallback (taken when the in-memory history is empty, e.g. after a
|
The DB fallback (taken when the in-memory history is empty, e.g. after a
|
||||||
restart) built the client response from every DB row with no such filter, so
|
restart) built the client response from every DB row with no such filter, so
|
||||||
hidden messages leaked to the client on DB-served sessions. The rebuilt
|
hidden messages leaked to the client on DB-served sessions. Hydration of
|
||||||
in-memory ``session.history`` must still keep them, though, so only the response
|
``session.history`` belongs to ``get_session``; this fallback only shapes the
|
||||||
is filtered.
|
response, so only the response is filtered.
|
||||||
|
|
||||||
get_session_history depends on the DB, the session manager and a FastAPI
|
get_session_history depends on the DB, the session manager and a FastAPI
|
||||||
request, so this pins the regression at the source level (as other route tests
|
request, so this pins the regression at the source level (as other route tests
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
"""Display pagination must stay separate from full model-context hydration."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter, FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from starlette.requests import Request
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from core.database import Base, ChatMessage as DbChatMessage, Session as DbSession
|
||||||
|
from core.models import ChatMessage, Session
|
||||||
|
from core.session_manager import SessionManager
|
||||||
|
from routes import chat_routes
|
||||||
|
from routes.history import history_routes
|
||||||
|
from routes import session_routes
|
||||||
|
from src.request_models import ChatRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _database():
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite://",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
engine,
|
||||||
|
tables=[DbSession.__table__, DbChatMessage.__table__],
|
||||||
|
)
|
||||||
|
return engine, sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_session(db_factory, *, session_id="session-1", message_count=6, stored_count=None):
|
||||||
|
"""Seed `message_count` real rows; `stored_count` overrides the denormalized
|
||||||
|
sessions.message_count column so drift can be reproduced."""
|
||||||
|
db = db_factory()
|
||||||
|
try:
|
||||||
|
db.add(
|
||||||
|
DbSession(
|
||||||
|
id=session_id,
|
||||||
|
name="Long chat",
|
||||||
|
endpoint_url="http://model.test/v1",
|
||||||
|
model="test-model",
|
||||||
|
owner="alice",
|
||||||
|
message_count=message_count if stored_count is None else stored_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
start = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
|
for index in range(message_count):
|
||||||
|
db.add(
|
||||||
|
DbChatMessage(
|
||||||
|
id=f"message-{index}",
|
||||||
|
session_id=session_id,
|
||||||
|
role="user" if index % 2 == 0 else "assistant",
|
||||||
|
content=f"content-{index}",
|
||||||
|
timestamp=start + timedelta(seconds=index),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_message_selects(statements):
|
||||||
|
return [
|
||||||
|
" ".join(statement.lower().split())
|
||||||
|
for statement in statements
|
||||||
|
if statement.lstrip().lower().startswith("select")
|
||||||
|
and "chat_messages" in statement.lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(db_factory, monkeypatch, sessions=None):
|
||||||
|
"""A real SessionManager bound to the temp DB, with load counting."""
|
||||||
|
monkeypatch.setattr("core.session_manager.SessionLocal", db_factory)
|
||||||
|
manager = object.__new__(SessionManager)
|
||||||
|
manager.upload_handler = None
|
||||||
|
manager.sessions = sessions if sessions is not None else {}
|
||||||
|
manager.full_loads = 0
|
||||||
|
|
||||||
|
original_load = manager._load_session_from_db
|
||||||
|
|
||||||
|
def counting_load(session_id):
|
||||||
|
manager.full_loads += 1
|
||||||
|
return original_load(session_id)
|
||||||
|
|
||||||
|
manager._load_session_from_db = counting_load
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_paginated_history_reads_only_count_and_requested_page(monkeypatch):
|
||||||
|
engine, db_factory = _database()
|
||||||
|
_seed_session(db_factory)
|
||||||
|
|
||||||
|
class DisplayOnlyManager:
|
||||||
|
def get_session(self, _session_id):
|
||||||
|
raise AssertionError("paginated display history must not hydrate model context")
|
||||||
|
|
||||||
|
monkeypatch.setattr(history_routes, "SessionLocal", db_factory)
|
||||||
|
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *_args: None)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(history_routes.setup_history_routes(DisplayOnlyManager()))
|
||||||
|
|
||||||
|
statements = []
|
||||||
|
|
||||||
|
def capture_sql(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||||
|
statements.append(statement)
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", capture_sql)
|
||||||
|
try:
|
||||||
|
response = TestClient(app).get("/api/history/session-1?limit=2")
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", capture_sql)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert [message["content"] for message in payload["history"]] == [
|
||||||
|
"content-4",
|
||||||
|
"content-5",
|
||||||
|
]
|
||||||
|
assert payload["total"] == 6
|
||||||
|
assert payload["offset"] == 4
|
||||||
|
assert payload["has_more_before"] is True
|
||||||
|
assert payload["has_more_after"] is False
|
||||||
|
|
||||||
|
# One COUNT for the total plus one page read — never a full-transcript
|
||||||
|
# select. The page bounds are asserted through the response above rather
|
||||||
|
# than by matching SQL text.
|
||||||
|
chat_selects = _chat_message_selects(statements)
|
||||||
|
assert len(chat_selects) == 2, chat_selects
|
||||||
|
assert sum("count(" in statement for statement in chat_selects) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_router_order_reaches_bounded_canonical_history(monkeypatch):
|
||||||
|
"""The assembled app must not shadow canonical history with session routes."""
|
||||||
|
engine, db_factory = _database()
|
||||||
|
_seed_session(db_factory, message_count=1200)
|
||||||
|
|
||||||
|
class DisplayOnlyManager:
|
||||||
|
def get_session(self, _session_id):
|
||||||
|
raise AssertionError("bounded initial history must not hydrate all messages")
|
||||||
|
|
||||||
|
manager = DisplayOnlyManager()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
session_routes,
|
||||||
|
"router",
|
||||||
|
APIRouter(prefix="/api", tags=["sessions"]),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(history_routes, "SessionLocal", db_factory)
|
||||||
|
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *_args: None)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(session_routes.setup_session_routes(manager, {}))
|
||||||
|
app.include_router(history_routes.setup_history_routes(manager))
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = TestClient(app).get("/api/history/session-1?limit=24")
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.request.url.params["limit"] == "24"
|
||||||
|
payload = response.json()
|
||||||
|
displayed = len(payload["history"])
|
||||||
|
assert 0 < displayed <= payload["limit"] <= 100
|
||||||
|
assert payload["total"] >= 1200
|
||||||
|
assert payload["has_more_before"] is True
|
||||||
|
assert displayed < payload["total"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_cached_history_hydrates_once_for_model_context(monkeypatch):
|
||||||
|
engine, db_factory = _database()
|
||||||
|
raw_multimodal = json.dumps(
|
||||||
|
[
|
||||||
|
{"type": "text", "text": "look at the source image"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,AAAA"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db = db_factory()
|
||||||
|
try:
|
||||||
|
db.add(
|
||||||
|
DbSession(
|
||||||
|
id="session-1",
|
||||||
|
name="Long chat",
|
||||||
|
endpoint_url="http://model.test/v1",
|
||||||
|
model="test-model",
|
||||||
|
owner="alice",
|
||||||
|
message_count=3,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
start = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
DbChatMessage(
|
||||||
|
id="message-0",
|
||||||
|
session_id="session-1",
|
||||||
|
role="user",
|
||||||
|
content=raw_multimodal,
|
||||||
|
meta_data=json.dumps(
|
||||||
|
{
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "upload-1",
|
||||||
|
"filename": "source.png",
|
||||||
|
"content_type": "image/png",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
timestamp=start,
|
||||||
|
),
|
||||||
|
DbChatMessage(
|
||||||
|
id="message-1",
|
||||||
|
session_id="session-1",
|
||||||
|
role="assistant",
|
||||||
|
content="answer",
|
||||||
|
timestamp=start + timedelta(seconds=1),
|
||||||
|
),
|
||||||
|
DbChatMessage(
|
||||||
|
id="message-2",
|
||||||
|
session_id="session-1",
|
||||||
|
role="system",
|
||||||
|
content="compaction summary",
|
||||||
|
meta_data=json.dumps({"hidden": True}),
|
||||||
|
timestamp=start + timedelta(seconds=2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
manager = _manager(
|
||||||
|
db_factory,
|
||||||
|
monkeypatch,
|
||||||
|
sessions={
|
||||||
|
"session-1": Session(
|
||||||
|
id="session-1",
|
||||||
|
name="Long chat",
|
||||||
|
endpoint_url="http://model.test/v1",
|
||||||
|
model="test-model",
|
||||||
|
owner="alice",
|
||||||
|
history=[ChatMessage("user", "stale partial cache")],
|
||||||
|
# Deliberately stale too: get_session must refresh metadata before
|
||||||
|
# checking whether the cached transcript is complete.
|
||||||
|
message_count=1,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
hydrated = manager.get_session("session-1")
|
||||||
|
first_full_loads = manager.full_loads
|
||||||
|
warm = manager.get_session("session-1")
|
||||||
|
second_full_loads = manager.full_loads
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
assert hydrated is warm
|
||||||
|
assert len(hydrated.history) == 3
|
||||||
|
assert first_full_loads == 1
|
||||||
|
assert second_full_loads == first_full_loads
|
||||||
|
|
||||||
|
context = hydrated.get_context_messages()
|
||||||
|
assert len(context) == 3
|
||||||
|
assert context[0]["content"][1]["image_url"]["url"] == "data:image/png;base64,AAAA"
|
||||||
|
assert context[0]["metadata"]["attachments"] == [
|
||||||
|
{
|
||||||
|
"id": "upload-1",
|
||||||
|
"filename": "source.png",
|
||||||
|
"content_type": "image/png",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
hidden_summary = next(message for message in context if message["role"] == "system")
|
||||||
|
assert hidden_summary["content"] == "compaction summary"
|
||||||
|
assert hidden_summary["metadata"]["hidden"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflated_message_count_column_does_not_reload_warm_sessions(monkeypatch):
|
||||||
|
"""A drifted-high sessions.message_count must not reload on every read.
|
||||||
|
|
||||||
|
`_persist_message` swallows a failed insert while `add_message` has already
|
||||||
|
appended in memory, so the next successful persist writes rows+1. Keyed on
|
||||||
|
that column, the hydration gate would stay true forever and re-select the
|
||||||
|
whole transcript on every send, edit, delete and truncate.
|
||||||
|
"""
|
||||||
|
engine, db_factory = _database()
|
||||||
|
_seed_session(db_factory, message_count=6, stored_count=8)
|
||||||
|
|
||||||
|
manager = _manager(db_factory, monkeypatch)
|
||||||
|
try:
|
||||||
|
session = manager.get_session("session-1")
|
||||||
|
cold_loads = manager.full_loads
|
||||||
|
for _ in range(3):
|
||||||
|
manager.get_session("session-1")
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
assert len(session.history) == 6
|
||||||
|
assert cold_loads == 1
|
||||||
|
assert manager.full_loads == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_low_message_count_column_still_hydrates_for_the_model(monkeypatch):
|
||||||
|
"""The other drift direction must not hand the model a truncated transcript.
|
||||||
|
|
||||||
|
`_persist_message` writes message_count = 0 when the session is not cached.
|
||||||
|
A partly-filled cache plus that stale-low column previously left the send
|
||||||
|
path with whatever RAM happened to hold.
|
||||||
|
"""
|
||||||
|
engine, db_factory = _database()
|
||||||
|
_seed_session(db_factory, message_count=6, stored_count=0)
|
||||||
|
|
||||||
|
manager = _manager(
|
||||||
|
db_factory,
|
||||||
|
monkeypatch,
|
||||||
|
sessions={
|
||||||
|
"session-1": Session(
|
||||||
|
id="session-1",
|
||||||
|
name="Long chat",
|
||||||
|
endpoint_url="http://model.test/v1",
|
||||||
|
model="test-model",
|
||||||
|
owner="alice",
|
||||||
|
history=[ChatMessage("user", "content-0")],
|
||||||
|
message_count=0,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
session = manager.get_session("session-1")
|
||||||
|
manager.get_session("session-1")
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
assert [message.content for message in session.history] == [
|
||||||
|
f"content-{index}" for index in range(6)
|
||||||
|
]
|
||||||
|
assert manager.full_loads == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_after_restart_copies_the_real_transcript(monkeypatch):
|
||||||
|
"""Forking reads source.history, so it must hydrate through get_session.
|
||||||
|
|
||||||
|
Display pagination no longer fills the cache, so a fork taken after a
|
||||||
|
restart used to return HTTP 200 with an empty conversation.
|
||||||
|
"""
|
||||||
|
engine, db_factory = _database()
|
||||||
|
_seed_session(db_factory, message_count=6)
|
||||||
|
|
||||||
|
# Restart state: metadata-only cache entry, exactly what load_sessions seeds.
|
||||||
|
manager = _manager(db_factory, monkeypatch)
|
||||||
|
manager.load_sessions()
|
||||||
|
|
||||||
|
monkeypatch.setattr(history_routes, "SessionLocal", db_factory)
|
||||||
|
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *_args: None)
|
||||||
|
monkeypatch.setattr("core.models._SESSION_MANAGER_INSTANCE", manager)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(history_routes.setup_history_routes(manager))
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = client.get("/api/history/session-1?limit=2")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert len(manager.sessions["session-1"].history) == 0
|
||||||
|
|
||||||
|
response = client.post("/api/session/session-1/fork", json={"keep_count": 4})
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["kept"] == 4
|
||||||
|
|
||||||
|
forked = manager.get_session(payload["id"])
|
||||||
|
assert [message.content for message in forked.history] == [
|
||||||
|
f"content-{index}" for index in range(4)
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class _ContextBuildReached(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _ToolPolicy:
|
||||||
|
block_all_tool_calls = False
|
||||||
|
|
||||||
|
def blocks(self, _tool_name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _ChatHandler:
|
||||||
|
async def handle_memory_command(self, _session, _message):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_request(path, payload):
|
||||||
|
raw = json.dumps(payload).encode()
|
||||||
|
sent = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal sent
|
||||||
|
if sent:
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
sent = True
|
||||||
|
return {"type": "http.request", "body": raw, "more_body": False}
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"asgi": {"version": "3.0"},
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "POST",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": path,
|
||||||
|
"raw_path": path.encode(),
|
||||||
|
"root_path": "",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [(b"content-type", b"application/json")],
|
||||||
|
"client": ("127.0.0.1", 1234),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
return Request(scope, receive)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_endpoint(router, path):
|
||||||
|
return next(route.endpoint for route in router.routes if route.path == path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("path", ["/api/chat", "/api/chat_stream"])
|
||||||
|
async def test_model_send_routes_hydrate_before_context_build(monkeypatch, path):
|
||||||
|
# A real SessionManager over a real (temp) DB — a stub here would only
|
||||||
|
# assert that the stub hydrates, not that SessionManager does.
|
||||||
|
engine, db_factory = _database()
|
||||||
|
_seed_session(db_factory, message_count=6, stored_count=8)
|
||||||
|
manager = _manager(db_factory, monkeypatch)
|
||||||
|
manager.load_sessions() # restart state: metadata only, no messages cached
|
||||||
|
contexts_built = []
|
||||||
|
|
||||||
|
async def assert_complete_context(session, *_args, **_kwargs):
|
||||||
|
contexts_built.append(session)
|
||||||
|
assert [message.content for message in session.history] == [
|
||||||
|
f"content-{index}" for index in range(6)
|
||||||
|
]
|
||||||
|
raise _ContextBuildReached
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_routes, "_set_user_time_from_request", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(chat_routes, "effective_user", lambda *_args: "alice")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_routes,
|
||||||
|
"_clear_orphaned_session_endpoint",
|
||||||
|
lambda *_args, **_kwargs: False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_routes,
|
||||||
|
"_recover_empty_session_model",
|
||||||
|
lambda *_args, **_kwargs: False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(chat_routes, "_enforce_chat_privileges", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_routes,
|
||||||
|
"build_effective_tool_policy",
|
||||||
|
lambda **_kwargs: _ToolPolicy(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(chat_routes, "build_chat_context", assert_complete_context)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_routes,
|
||||||
|
"_resolve_request_workspace",
|
||||||
|
lambda *_args: (None, False),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(chat_routes, "_classify_tool_intent", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_routes,
|
||||||
|
"_is_contextual_web_followup",
|
||||||
|
lambda *_args: False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_routes,
|
||||||
|
"_is_contextual_browser_followup",
|
||||||
|
lambda *_args: False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_routes,
|
||||||
|
"_resolve_workspace_from_message_path",
|
||||||
|
lambda *_args: (None, None),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_routes,
|
||||||
|
"_reconcile_selected_route_from_request",
|
||||||
|
lambda *_args, **_kwargs: False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(chat_routes, "resolve_session_auth", lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(chat_routes, "get_session_mode", lambda *_args: "chat")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_routes,
|
||||||
|
"_is_image_generation_session",
|
||||||
|
lambda *_args, **_kwargs: False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(chat_routes, "web_search_enabled_for_turn", lambda *_args: False)
|
||||||
|
|
||||||
|
router = chat_routes.setup_chat_routes(
|
||||||
|
manager,
|
||||||
|
_ChatHandler(),
|
||||||
|
object(),
|
||||||
|
object(),
|
||||||
|
object(),
|
||||||
|
object(),
|
||||||
|
)
|
||||||
|
endpoint = _route_endpoint(router, path)
|
||||||
|
|
||||||
|
async def send():
|
||||||
|
if path == "/api/chat":
|
||||||
|
await endpoint(
|
||||||
|
_json_request(path, {}),
|
||||||
|
ChatRequest(message="hello", session="session-1"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await endpoint(
|
||||||
|
_json_request(
|
||||||
|
path,
|
||||||
|
{"message": "hello", "session": "session-1"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(_ContextBuildReached):
|
||||||
|
await send()
|
||||||
|
first_loads = manager.full_loads
|
||||||
|
|
||||||
|
# Second send on the now-warm session: the transcript is complete, so
|
||||||
|
# it must be served from RAM even though sessions.message_count is
|
||||||
|
# still drifted high in the DB.
|
||||||
|
with pytest.raises(_ContextBuildReached):
|
||||||
|
await send()
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
assert len(contexts_built) == 2
|
||||||
|
assert contexts_built[0] is contexts_built[1]
|
||||||
|
assert first_loads == 1
|
||||||
|
assert manager.full_loads == 1
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Source-level wiring guards for live-thinking stream lifecycle.
|
||||||
|
|
||||||
|
The pure scheduler suite covers timing behavior. These assertions pin the
|
||||||
|
browser-only integration seams that are impractical to import without the full
|
||||||
|
application DOM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_CHAT = (Path(__file__).resolve().parent.parent / "static" / "js" / "chat.js").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _between(start: str, end: str) -> str:
|
||||||
|
return _CHAT.split(start, 1)[1].split(end, 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_thinking_delta_short_circuits_before_cumulative_normalization():
|
||||||
|
delta_handler = _between(
|
||||||
|
"let _delta = json.delta;",
|
||||||
|
"} else if (json.type === 'research_progress')",
|
||||||
|
)
|
||||||
|
delta_path = _between(
|
||||||
|
"// Detect thinking-in-progress:",
|
||||||
|
"} else if (json.type === 'research_progress')",
|
||||||
|
)
|
||||||
|
guard = "if (!_thinkingAnalysisGate.shouldAnalyze(roundText, {"
|
||||||
|
normalize = "markdownModule.normalizeThinkingMarkup(roundText)"
|
||||||
|
assert guard in delta_path
|
||||||
|
assert delta_path.index(guard) < delta_path.index(normalize)
|
||||||
|
assert "_queueLiveThinking(roundText);" in delta_path
|
||||||
|
assert "createThinkingAnalysisGate" in _CHAT
|
||||||
|
projector_append = "_roundDisplayProjector.append(_delta, roundText);"
|
||||||
|
assert projector_append in delta_handler
|
||||||
|
assert delta_handler.index(projector_append) < delta_handler.index(guard)
|
||||||
|
assert "_renderStream({ knownNormal: true, displayText: _roundDisplayProjector.current() });" in delta_path
|
||||||
|
assert "_replyDisplayProjector.append(_delta, roundReplyText)" in delta_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_close_grace_expires_without_another_delta():
|
||||||
|
assert "function _scheduleThinkingGrace()" in _CHAT
|
||||||
|
grace = _between(
|
||||||
|
"function _scheduleThinkingGrace()",
|
||||||
|
"function _replyAfterClosedThinking",
|
||||||
|
)
|
||||||
|
assert "setTimeout(() =>" in grace
|
||||||
|
assert "_finishLiveThinkingTransition();" in grace
|
||||||
|
cancel = _between("_cancelLiveThinkingWork = () =>", "function _finalizeLiveThinking")
|
||||||
|
assert "_cancelThinkingGrace();" in cancel
|
||||||
|
delta_path = _between(
|
||||||
|
"// Detect thinking-in-progress:",
|
||||||
|
"} else if (json.type === 'research_progress')",
|
||||||
|
)
|
||||||
|
false_close = _between(
|
||||||
|
"// Detect false close:",
|
||||||
|
"if (hasUnclosedThink && !isThinking)",
|
||||||
|
)
|
||||||
|
assert "Do NOT require a prior unclosed delta" in false_close
|
||||||
|
assert "_afterClose &&" in false_close
|
||||||
|
assert "&& isThinking" not in false_close.split("let _falseCloseDeadline", 1)[1].split("if (isThinking)", 1)[0]
|
||||||
|
assert "_thinkingRecheckAt = _falseCloseDeadline || 0;" in delta_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_paths_use_one_authoritative_rich_round_render():
|
||||||
|
tool_path = _between(
|
||||||
|
"} else if (json.type === 'tool_start') {",
|
||||||
|
"} else if (json.type === 'tool_output') {",
|
||||||
|
)
|
||||||
|
assert "_endLiveThinkingSection({ rich: false });" in tool_path
|
||||||
|
assert tool_path.count("_finalizeRoundRender();") == 1
|
||||||
|
assert "_renderStream();" not in tool_path
|
||||||
|
|
||||||
|
agent_path = _between(
|
||||||
|
"} else if (json.type === 'agent_step') {",
|
||||||
|
"} else if (json.type === 'budget_exceeded') {",
|
||||||
|
)
|
||||||
|
assert "_endLiveThinkingSection({ rich: false });" in agent_path
|
||||||
|
assert agent_path.count("_finalizeRoundRender();") == 1
|
||||||
|
assert "if (!roundFinalized)" not in agent_path
|
||||||
|
|
||||||
|
catch_path = _between(
|
||||||
|
"// foreground session's text.\n const _isBgCatch",
|
||||||
|
"} finally {",
|
||||||
|
)
|
||||||
|
assert "if (_isBgCatch)" in catch_path
|
||||||
|
assert "_cancelLiveThinkingWork();" in catch_path
|
||||||
|
assert "_catchTerminalView = _finalizeInterruptedView();" in catch_path
|
||||||
|
assert "_finalizeRoundRender();" not in catch_path
|
||||||
|
assert "_endThinkingOnTerminalPath({ rich: false });" in catch_path
|
||||||
|
assert "const _catchViewHolder = _catchTerminalView?.holder || holder;" in catch_path
|
||||||
|
|
||||||
|
round_finalizer = _between(
|
||||||
|
"_finalizeRoundRender = () => {",
|
||||||
|
"_finalizeInterruptedView = () => {",
|
||||||
|
)
|
||||||
|
assert "if (roundFinalized) return roundFinalization;" in round_finalizer
|
||||||
|
assert round_finalizer.index("processWithThinking") < round_finalizer.rindex("roundFinalized = true;")
|
||||||
|
assert "lastContentRoundHolder = terminalHolder;" in round_finalizer
|
||||||
|
|
||||||
|
interrupted_finalizer = _between(
|
||||||
|
"_finalizeInterruptedView = () => {",
|
||||||
|
"function _replyAfterClosedThinking",
|
||||||
|
)
|
||||||
|
assert "finalization?.hasContent" in interrupted_finalizer
|
||||||
|
assert "lastContentRoundHolder || finalization?.holder" in interrupted_finalizer
|
||||||
|
|
||||||
|
stop_path = _between(
|
||||||
|
"// Render whatever was accumulated so far",
|
||||||
|
"// Reset button state",
|
||||||
|
)
|
||||||
|
assert "const _stoppedViewHolder = _terminalView?.holder || currentHolder;" in stop_path
|
||||||
|
assert "_stoppedViewHolder.querySelector('.body').appendChild(stoppedIndicator);" in stop_path
|
||||||
|
|
||||||
|
done_path = _between(
|
||||||
|
"if (data === '[DONE]') {",
|
||||||
|
"try {\n const json = JSON.parse(data);",
|
||||||
|
)
|
||||||
|
assert "_finalizeLiveThinking(_closedThinkingText(roundText), false);" in done_path
|
||||||
|
assert "_renderStream();" not in done_path
|
||||||
|
|
||||||
|
post_loop = _between(
|
||||||
|
"if (!_streamSawDone) {",
|
||||||
|
"// --- Final render (skip if stream was ever backgrounded or currently in background) ---",
|
||||||
|
)
|
||||||
|
assert "_cancelLiveThinkingWork();" in post_loop
|
||||||
|
assert "_renderStream();" not in post_loop
|
||||||
|
|
||||||
|
recovery_path = _between(
|
||||||
|
"function _tryAutoRecover(holder, accumulated, sessionId)",
|
||||||
|
"function _removeStallBanner()",
|
||||||
|
)
|
||||||
|
assert "processWithThinking" not in recovery_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_detach_synchronously_cancels_delayed_view_work():
|
||||||
|
registration = _between("_activeStreams.set(streamSessionId", "_syncForegroundStreamGlobals();")
|
||||||
|
assert "cancelViewWork: () => _cancelLiveThinkingWork()" in registration
|
||||||
|
|
||||||
|
detach = _between("export function detachCurrentStream", "// _notifyStreamComplete")
|
||||||
|
cancel = "if (active.cancelViewWork) active.cancelViewWork();"
|
||||||
|
background = "_backgroundStreams.set(sessionId"
|
||||||
|
assert cancel in detach
|
||||||
|
assert detach.index(cancel) < detach.index(background)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Runs the live-thinking throttle's behavioral suite under pytest.
|
||||||
|
|
||||||
|
Behavior lives in tests/live_thinking_scheduler.test.mjs (node:test, no DOM).
|
||||||
|
This wrapper only exists so the JS suite runs in the normal pytest job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_live_thinking_scheduler_behavior():
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "--test", "tests/live_thinking_scheduler.test.mjs"],
|
||||||
|
cwd=_REPO,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(
|
||||||
|
f"node --test failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""Exercise sessions.js and startupShell.js together at the bootstrap seam.
|
||||||
|
|
||||||
|
The dependency-heavy session module is copied unchanged except for redirecting
|
||||||
|
its static imports to tiny browser stubs. The real loadSessions implementation
|
||||||
|
and the real startup-shell coordinator then run together under Node.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_SESSIONS = _REPO / "static" / "js" / "sessions.js"
|
||||||
|
_SHELL_URL = (_REPO / "static" / "js" / "startupShell.js").as_uri()
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
_IMPORT_REWRITES = {
|
||||||
|
"import Storage from './storage.js';": "import Storage from './storage.mjs';",
|
||||||
|
"import uiModule, { autoResize, styledPrompt } from './ui.js';": (
|
||||||
|
"import uiModule, { autoResize, styledPrompt } from './ui.mjs';"
|
||||||
|
),
|
||||||
|
"import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';": (
|
||||||
|
"import chatRenderer from './chatRenderer.mjs';"
|
||||||
|
),
|
||||||
|
"import { providerLogo } from './providers.js';": (
|
||||||
|
"import { providerLogo } from './providers.mjs';"
|
||||||
|
),
|
||||||
|
"import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';": (
|
||||||
|
"import { initModelPicker, updateModelPicker } from './modelPicker.mjs';"
|
||||||
|
),
|
||||||
|
"import themeModule from './theme.js';": "import themeModule from './theme.mjs';",
|
||||||
|
"import spinnerModule from './spinner.js';": "import spinnerModule from './spinner.mjs';",
|
||||||
|
}
|
||||||
|
|
||||||
|
_STUBS = {
|
||||||
|
"storage.mjs": r"""
|
||||||
|
const Storage = {
|
||||||
|
get: (key, fallback = null) => localStorage.getItem(key) ?? fallback,
|
||||||
|
set: (key, value) => localStorage.setItem(key, value),
|
||||||
|
remove: (key) => localStorage.removeItem(key),
|
||||||
|
getJSON: (key, fallback) => {
|
||||||
|
try { return JSON.parse(localStorage.getItem(key) ?? JSON.stringify(fallback)); }
|
||||||
|
catch (_) { return fallback; }
|
||||||
|
},
|
||||||
|
setJSON: (key, value) => localStorage.setItem(key, JSON.stringify(value)),
|
||||||
|
};
|
||||||
|
export default Storage;
|
||||||
|
""",
|
||||||
|
"ui.mjs": r"""
|
||||||
|
export const autoResize = () => {};
|
||||||
|
export const styledPrompt = async () => null;
|
||||||
|
const ui = {
|
||||||
|
el: (id) => document.getElementById(id),
|
||||||
|
showError: (message) => globalThis.__sessionErrors.push(String(message)),
|
||||||
|
showToast: () => {},
|
||||||
|
styledConfirm: async () => true,
|
||||||
|
};
|
||||||
|
export default ui;
|
||||||
|
""",
|
||||||
|
"chatRenderer.mjs": (
|
||||||
|
"export default { addMessage: () => null, hideWelcomeScreen: () => {} };\n"
|
||||||
|
),
|
||||||
|
"providers.mjs": "export const providerLogo = () => '';\n",
|
||||||
|
"modelPicker.mjs": (
|
||||||
|
"export const initModelPicker = () => {};\n"
|
||||||
|
"export const updateModelPicker = () => {};\n"
|
||||||
|
),
|
||||||
|
"theme.mjs": "export default {};\n",
|
||||||
|
"spinner.mjs": "export default {};\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
_HARNESS = r"""
|
||||||
|
const SESSIONS_URL = 'SESSIONS_PATH';
|
||||||
|
const SHELL_URL = 'SHELL_PATH';
|
||||||
|
|
||||||
|
function makeStore() {
|
||||||
|
const values = new Map();
|
||||||
|
return {
|
||||||
|
getItem(key) { return values.has(key) ? values.get(key) : null; },
|
||||||
|
setItem(key, value) { values.set(key, String(value)); },
|
||||||
|
removeItem(key) { values.delete(key); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClassList() {
|
||||||
|
const values = new Set();
|
||||||
|
return {
|
||||||
|
add(...names) { names.forEach(name => values.add(name)); },
|
||||||
|
remove(...names) { names.forEach(name => values.delete(name)); },
|
||||||
|
contains(name) { return values.has(name); },
|
||||||
|
toggle(name, force) {
|
||||||
|
const enabled = force === undefined ? !values.has(name) : !!force;
|
||||||
|
if (enabled) values.add(name); else values.delete(name);
|
||||||
|
return enabled;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWorld() {
|
||||||
|
const byId = new Map();
|
||||||
|
const frames = [];
|
||||||
|
const cancelledFrames = new Set();
|
||||||
|
const timers = [];
|
||||||
|
let nextFrame = 1;
|
||||||
|
let historyWrites = 0;
|
||||||
|
|
||||||
|
function makeElement(id = '') {
|
||||||
|
let html = '';
|
||||||
|
const element = {
|
||||||
|
id,
|
||||||
|
dataset: {},
|
||||||
|
style: {},
|
||||||
|
classList: makeClassList(),
|
||||||
|
children: [],
|
||||||
|
status: null,
|
||||||
|
value: '',
|
||||||
|
disabled: false,
|
||||||
|
removed: false,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
setAttribute(name, value) { this[name] = value; },
|
||||||
|
getAttribute(name) { return this[name] ?? null; },
|
||||||
|
appendChild(child) { this.children.push(child); return child; },
|
||||||
|
insertBefore(child) { this.children.unshift(child); return child; },
|
||||||
|
contains() { return false; },
|
||||||
|
closest() { return null; },
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === '[data-session-list-status]') return this.status;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
querySelectorAll() { return []; },
|
||||||
|
focus() { document.activeElement = this; },
|
||||||
|
remove() { this.removed = true; if (this.id) byId.delete(this.id); },
|
||||||
|
};
|
||||||
|
Object.defineProperty(element, 'innerHTML', {
|
||||||
|
get() { return html; },
|
||||||
|
set(value) {
|
||||||
|
html = String(value);
|
||||||
|
if (id === 'session-list' && html === '') {
|
||||||
|
const row = byId.get('session-list-loading');
|
||||||
|
if (row) row.remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
activeElement: null,
|
||||||
|
getElementById: (id) => byId.get(id) || null,
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: (tag) => makeElement(tag),
|
||||||
|
createDocumentFragment: () => makeElement('fragment'),
|
||||||
|
addEventListener() {},
|
||||||
|
};
|
||||||
|
globalThis.document = document;
|
||||||
|
globalThis.localStorage = makeStore();
|
||||||
|
globalThis.sessionStorage = makeStore();
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
value: { platform: 'Linux' },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
globalThis.history = { replaceState() { historyWrites += 1; } };
|
||||||
|
globalThis.window = {
|
||||||
|
document,
|
||||||
|
innerWidth: 1024,
|
||||||
|
innerHeight: 768,
|
||||||
|
location: { origin: 'http://odysseus.test', hash: '', pathname: '/', href: '/' },
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
chatModule: {
|
||||||
|
detachCurrentStream() {},
|
||||||
|
showWelcomeScreen() {},
|
||||||
|
},
|
||||||
|
__odysseusDefaultChat: {
|
||||||
|
endpoint_url: 'http://model.test',
|
||||||
|
model: 'test/model',
|
||||||
|
endpoint_id: 'endpoint-1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.location = window.location;
|
||||||
|
globalThis.requestAnimationFrame = (fn) => {
|
||||||
|
const id = nextFrame++;
|
||||||
|
frames.push({ id, fn });
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
globalThis.cancelAnimationFrame = (id) => cancelledFrames.add(id);
|
||||||
|
globalThis.setTimeout = (fn, ms) => { timers.push({ fn, ms }); return timers.length; };
|
||||||
|
globalThis.clearTimeout = () => {};
|
||||||
|
globalThis.__sessionErrors = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
add(id, options = {}) {
|
||||||
|
const element = makeElement(id);
|
||||||
|
if (options.statusText !== undefined) {
|
||||||
|
element.status = { textContent: options.statusText };
|
||||||
|
}
|
||||||
|
if (options.value !== undefined) element.value = options.value;
|
||||||
|
byId.set(id, element);
|
||||||
|
return element;
|
||||||
|
},
|
||||||
|
paint(rounds = 1) {
|
||||||
|
for (let i = 0; i < rounds; i += 1) {
|
||||||
|
const due = frames.splice(0, frames.length);
|
||||||
|
for (const frame of due) {
|
||||||
|
if (!cancelledFrames.has(frame.id)) frame.fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runTimers() {
|
||||||
|
const due = timers.splice(0, timers.length);
|
||||||
|
for (const timer of due) timer.fn();
|
||||||
|
},
|
||||||
|
byId,
|
||||||
|
historyWrites: () => historyWrites,
|
||||||
|
resetHistoryWrites: () => { historyWrites = 0; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const world = makeWorld();
|
||||||
|
world.add('session-list');
|
||||||
|
world.add('sessions-section');
|
||||||
|
const message = world.add('message', { value: 'draft before seed' });
|
||||||
|
|
||||||
|
const responses = [
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => [{ id: 'existing', name: 'Existing', folder: 'Assistant', archived: false }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
json: async () => ({ detail: 'temporarily unavailable' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let fetchCount = 0;
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
fetchCount += 1;
|
||||||
|
const response = responses.shift();
|
||||||
|
if (!response) throw new Error('unexpected fetch');
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessions = await import(SESSIONS_URL + '?bootstrap');
|
||||||
|
const shell = await import(SHELL_URL + '?bootstrap');
|
||||||
|
|
||||||
|
const seeded = await sessions.loadSessions();
|
||||||
|
world.paint(1);
|
||||||
|
localStorage.setItem('lastSessionId', 'existing');
|
||||||
|
message.value = 'draft must survive';
|
||||||
|
document.activeElement = null;
|
||||||
|
world.resetHistoryWrites();
|
||||||
|
const loader = world.add('app-loader');
|
||||||
|
const row = world.add('session-list-loading', { statusText: 'Loading chats…' });
|
||||||
|
let opened = 0;
|
||||||
|
shell.deferRouteOpener('/email', () => { opened += 1; });
|
||||||
|
|
||||||
|
const hydrated = await shell.settleSessionHydration(() => sessions.loadSessions());
|
||||||
|
const beforePaint = row.status.textContent;
|
||||||
|
world.paint(2);
|
||||||
|
world.runTimers();
|
||||||
|
const staleRouteRan = shell.runDeferredRouteOpener({ sessionsSettled: true });
|
||||||
|
|
||||||
|
const errorsBeforeAuth = __sessionErrors.length;
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
fetchCount += 1;
|
||||||
|
const response = { ok: false, status: 401, json: async () => ({ detail: 'expired' }) };
|
||||||
|
window.location.href = '/login'; // app.js global fetch-wrapper behaviour
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
const authResult = await sessions.loadSessions();
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
seeded,
|
||||||
|
hydrated,
|
||||||
|
beforePaint,
|
||||||
|
afterPaint: row.status.textContent,
|
||||||
|
rowStillPresent: world.byId.has('session-list-loading'),
|
||||||
|
loaderRemoved: loader.removed,
|
||||||
|
opened,
|
||||||
|
staleRouteRan,
|
||||||
|
fetchCount,
|
||||||
|
sessionIds: sessions.getSessions().map(session => session.id),
|
||||||
|
pendingChat: sessions.hasPendingChat(),
|
||||||
|
draft: message.value,
|
||||||
|
lastSessionId: localStorage.getItem('lastSessionId'),
|
||||||
|
historyWrites: world.historyWrites(),
|
||||||
|
errors: __sessionErrors,
|
||||||
|
authResult,
|
||||||
|
authRedirect: window.location.href,
|
||||||
|
authAddedError: __sessionErrors.length !== errorsBeforeAuth,
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def results(tmp_path_factory):
|
||||||
|
if not _HAS_NODE:
|
||||||
|
pytest.skip("node is not installed")
|
||||||
|
|
||||||
|
module_dir = tmp_path_factory.mktemp("session-bootstrap-js")
|
||||||
|
source = _SESSIONS.read_text(encoding="utf-8")
|
||||||
|
for original, replacement in _IMPORT_REWRITES.items():
|
||||||
|
assert original in source, f"sessions import changed: {original}"
|
||||||
|
source = source.replace(original, replacement, 1)
|
||||||
|
sessions_module = module_dir / "sessions.mjs"
|
||||||
|
sessions_module.write_text(source, encoding="utf-8")
|
||||||
|
for name, stub in _STUBS.items():
|
||||||
|
(module_dir / name).write_text(stub, encoding="utf-8")
|
||||||
|
|
||||||
|
harness = _HARNESS.replace("SESSIONS_PATH", sessions_module.as_uri()).replace(
|
||||||
|
"SHELL_PATH", _SHELL_URL
|
||||||
|
)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module", "-e", harness],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, f"node harness failed:\n{proc.stderr}"
|
||||||
|
return json.loads(proc.stdout.strip().splitlines()[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_fulfilled_503_is_not_applied_as_an_empty_session_list(results):
|
||||||
|
assert results["seeded"] is True
|
||||||
|
assert results["hydrated"] is False
|
||||||
|
assert results["sessionIds"] == ["existing"]
|
||||||
|
assert results["pendingChat"] is False, "failure created a default direct chat"
|
||||||
|
assert results["draft"] == "draft must survive"
|
||||||
|
assert results["lastSessionId"] == "existing"
|
||||||
|
assert results["historyWrites"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fulfilled_503_keeps_failure_state_and_route_deferred(results):
|
||||||
|
assert results["beforePaint"] == "Loading chats…"
|
||||||
|
assert results["afterPaint"] == "Chats unavailable"
|
||||||
|
assert results["rowStillPresent"] is True
|
||||||
|
assert results["loaderRemoved"] is True
|
||||||
|
assert results["opened"] == 0
|
||||||
|
assert results["staleRouteRan"] is False
|
||||||
|
assert results["errors"] == [
|
||||||
|
"Failed to load sessions: temporarily unavailable",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_401_keeps_global_auth_redirect_contract(results):
|
||||||
|
assert results["authResult"] is False
|
||||||
|
assert results["authRedirect"] == "/login"
|
||||||
|
assert results["authAddedError"] is False
|
||||||
|
assert results["sessionIds"] == ["existing"]
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""Pin the startup shell contract (static/js/startupShell.js).
|
||||||
|
|
||||||
|
Driven through `node --input-type=module` against a stub DOM and a manually
|
||||||
|
pumped frame/timer clock, so the real module runs without a browser (same
|
||||||
|
approach as test_composer_arrow_up_recall_js.py). Skips when `node` is absent.
|
||||||
|
|
||||||
|
Locks in the behaviour #5926 asks for: the shell is revealed one paint after
|
||||||
|
wiring and does not wait on /api/sessions; the loader node survives hydration
|
||||||
|
as a startup sentinel but is always retired once hydration settles; the sidebar
|
||||||
|
owns its own loading/failure row and a successful zero-session render never
|
||||||
|
shows a false failure; and a URL route opens only after the data it actually
|
||||||
|
needs is authoritatively available.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_MODULE = _REPO / "static" / "js" / "startupShell.js"
|
||||||
|
_MODULE_URL = _MODULE.as_uri()
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
_HARNESS = r"""
|
||||||
|
const MODULE_URL = 'MODULE_PATH';
|
||||||
|
|
||||||
|
// ── Stub DOM + a clock we pump by hand ────────────────────────────────────
|
||||||
|
function makeWorld() {
|
||||||
|
const byId = new Map();
|
||||||
|
const frames = [];
|
||||||
|
const timers = [];
|
||||||
|
const world = {
|
||||||
|
byId,
|
||||||
|
waveStops: 0,
|
||||||
|
addElement(id, { statusText = null } = {}) {
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
dataset: {},
|
||||||
|
style: {},
|
||||||
|
attrs: {},
|
||||||
|
removed: false,
|
||||||
|
status: null,
|
||||||
|
setAttribute(k, v) { this.attrs[k] = v; },
|
||||||
|
getAttribute(k) { return this.attrs[k]; },
|
||||||
|
remove() { this.removed = true; byId.delete(this.id); },
|
||||||
|
querySelector(sel) {
|
||||||
|
return sel === '[data-session-list-status]' ? this.status : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (statusText !== null) el.status = { textContent: statusText };
|
||||||
|
byId.set(id, el);
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
// One "paint" = one round of already-queued rAF callbacks. afterNextPaint
|
||||||
|
// chains two, so a committed paint takes two rounds.
|
||||||
|
paint(rounds = 1) {
|
||||||
|
for (let i = 0; i < rounds; i++) {
|
||||||
|
const due = frames.splice(0, frames.length);
|
||||||
|
for (const fn of due) fn();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runTimers() {
|
||||||
|
const due = timers.splice(0, timers.length);
|
||||||
|
for (const t of due) t.fn();
|
||||||
|
},
|
||||||
|
pendingTimers() { return timers.length; },
|
||||||
|
};
|
||||||
|
globalThis.document = { getElementById: (id) => byId.get(id) || null };
|
||||||
|
globalThis.window = { __odysseusLoaderWaveStop: () => { world.waveStops += 1; } };
|
||||||
|
globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
|
||||||
|
globalThis.setTimeout = (fn, ms) => { timers.push({ fn, ms }); return timers.length; };
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh module instance per case so deferred-route state cannot leak.
|
||||||
|
let _instance = 0;
|
||||||
|
async function loadModule() {
|
||||||
|
_instance += 1;
|
||||||
|
return import(MODULE_URL + '?case=' + _instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loaderSnapshot(loader) {
|
||||||
|
return {
|
||||||
|
revealed: loader.dataset.shellRevealed === 'true',
|
||||||
|
opacity: loader.style.opacity ?? null,
|
||||||
|
pointerEvents: loader.style.pointerEvents ?? null,
|
||||||
|
ariaHidden: loader.getAttribute('aria-hidden') ?? null,
|
||||||
|
removed: loader.removed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases = {};
|
||||||
|
|
||||||
|
cases.reveal_waits_one_paint_then_keeps_node = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
const loader = w.addElement('app-loader');
|
||||||
|
const shell = await loadModule();
|
||||||
|
shell.revealApplicationShellAfterPaint();
|
||||||
|
const beforePaint = loaderSnapshot(loader);
|
||||||
|
w.paint(1);
|
||||||
|
const afterOneFrame = loaderSnapshot(loader);
|
||||||
|
w.paint(1);
|
||||||
|
return {
|
||||||
|
beforePaint,
|
||||||
|
afterOneFrame,
|
||||||
|
afterPaint: loaderSnapshot(loader),
|
||||||
|
waveStops: w.waveStops,
|
||||||
|
stillInDocument: w.byId.has('app-loader'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cases.reveal_is_idempotent = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
const loader = w.addElement('app-loader');
|
||||||
|
const shell = await loadModule();
|
||||||
|
shell.revealApplicationShellAfterPaint();
|
||||||
|
shell.revealApplicationShellAfterPaint();
|
||||||
|
w.paint(2);
|
||||||
|
shell.revealApplicationShellAfterPaint();
|
||||||
|
w.paint(2);
|
||||||
|
return { waveStops: w.waveStops, snapshot: loaderSnapshot(loader) };
|
||||||
|
};
|
||||||
|
|
||||||
|
cases.remove_retires_the_loader_node = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
const loader = w.addElement('app-loader');
|
||||||
|
const shell = await loadModule();
|
||||||
|
shell.removeApplicationLoader();
|
||||||
|
const beforeTimers = loaderSnapshot(loader);
|
||||||
|
w.runTimers();
|
||||||
|
return { beforeTimers, afterTimers: loaderSnapshot(loader) };
|
||||||
|
};
|
||||||
|
|
||||||
|
cases.failed_hydration_marks_sidebar_row = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
w.addElement('app-loader');
|
||||||
|
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
|
||||||
|
const shell = await loadModule();
|
||||||
|
await shell.settleSessionHydration(() => Promise.reject(new Error('boom')));
|
||||||
|
const beforePaint = row.status.textContent;
|
||||||
|
w.paint(2);
|
||||||
|
w.runTimers();
|
||||||
|
return {
|
||||||
|
beforePaint,
|
||||||
|
afterPaint: row.status.textContent,
|
||||||
|
loaderRemoved: !w.byId.has('app-loader'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// A successful load with zero sessions must not schedule a failure write.
|
||||||
|
cases.zero_session_success_shows_no_failure = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
w.addElement('app-loader');
|
||||||
|
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
|
||||||
|
const shell = await loadModule();
|
||||||
|
await shell.settleSessionHydration(() => Promise.resolve(true));
|
||||||
|
w.paint(1);
|
||||||
|
row.remove(); // renderSessionList() clearing #session-list
|
||||||
|
w.paint(1);
|
||||||
|
return { statusText: row.status.textContent, rowRemoved: row.removed };
|
||||||
|
};
|
||||||
|
|
||||||
|
// The whole point is getting /api/sessions off the critical path, not later.
|
||||||
|
cases.hydration_starts_synchronously = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
w.addElement('app-loader');
|
||||||
|
const shell = await loadModule();
|
||||||
|
let started = false;
|
||||||
|
const done = shell.settleSessionHydration(() => { started = true; return Promise.resolve(true); });
|
||||||
|
const startedBeforeAwait = started;
|
||||||
|
await done;
|
||||||
|
return { startedBeforeAwait };
|
||||||
|
};
|
||||||
|
|
||||||
|
cases.synchronous_load_failure_still_settles = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
w.addElement('app-loader');
|
||||||
|
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
|
||||||
|
const shell = await loadModule();
|
||||||
|
let opened = 0;
|
||||||
|
shell.deferRouteOpener('/email', () => { opened += 1; });
|
||||||
|
let threw = false;
|
||||||
|
let succeeded = true;
|
||||||
|
try {
|
||||||
|
succeeded = await shell.settleSessionHydration(() => { throw new Error('module blew up'); });
|
||||||
|
} catch (_) { threw = true; }
|
||||||
|
w.paint(2);
|
||||||
|
w.runTimers();
|
||||||
|
return {
|
||||||
|
threw,
|
||||||
|
succeeded,
|
||||||
|
opened,
|
||||||
|
statusText: row.status.textContent,
|
||||||
|
loaderRemoved: !w.byId.has('app-loader'),
|
||||||
|
ranAfterFailure: shell.runDeferredRouteOpener({ sessionsSettled: true }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cases.route_without_session_data_opens_before_hydration = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
w.addElement('app-loader');
|
||||||
|
const shell = await loadModule();
|
||||||
|
let opened = 0;
|
||||||
|
shell.deferRouteOpener('/notes', () => { opened += 1; });
|
||||||
|
const ranEarly = shell.runDeferredRouteOpener();
|
||||||
|
const openedAfterEarly = opened;
|
||||||
|
const ranAgain = shell.runDeferredRouteOpener({ sessionsSettled: true });
|
||||||
|
return { ranEarly, openedAfterEarly, ranAgain, opened };
|
||||||
|
};
|
||||||
|
|
||||||
|
cases.route_with_session_data_waits_for_hydration = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
w.addElement('app-loader');
|
||||||
|
const shell = await loadModule();
|
||||||
|
let opened = 0;
|
||||||
|
shell.deferRouteOpener('/email', () => { opened += 1; });
|
||||||
|
const ranEarly = shell.runDeferredRouteOpener();
|
||||||
|
const openedAfterEarly = opened;
|
||||||
|
const succeeded = await shell.settleSessionHydration(() => Promise.resolve(true));
|
||||||
|
return {
|
||||||
|
ranEarly,
|
||||||
|
openedAfterEarly,
|
||||||
|
openedAfterHydration: opened,
|
||||||
|
succeeded,
|
||||||
|
needsSessions: [shell.routeNeedsSessionData('/email'), shell.routeNeedsSessionData('/notes')],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cases.missing_session_module_keeps_route_deferred = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
w.addElement('app-loader');
|
||||||
|
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
|
||||||
|
const shell = await loadModule();
|
||||||
|
let opened = 0;
|
||||||
|
shell.deferRouteOpener('/email', () => { opened += 1; });
|
||||||
|
const succeeded = await shell.settleSessionHydration(null);
|
||||||
|
w.paint(2);
|
||||||
|
w.runTimers();
|
||||||
|
return {
|
||||||
|
opened,
|
||||||
|
succeeded,
|
||||||
|
statusText: row.status.textContent,
|
||||||
|
loaderRemoved: !w.byId.has('app-loader'),
|
||||||
|
ranAfterFailure: shell.runDeferredRouteOpener({ sessionsSettled: true }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cases.throwing_route_opener_is_contained = async () => {
|
||||||
|
const w = makeWorld();
|
||||||
|
w.addElement('app-loader');
|
||||||
|
const shell = await loadModule();
|
||||||
|
shell.deferRouteOpener('/notes', () => { throw new Error('opener blew up'); });
|
||||||
|
let threw = false;
|
||||||
|
let ran = false;
|
||||||
|
try { ran = shell.runDeferredRouteOpener(); } catch (_) { threw = true; }
|
||||||
|
return { threw, ran, ranAgain: shell.runDeferredRouteOpener({ sessionsSettled: true }) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = {};
|
||||||
|
for (const [name, fn] of Object.entries(cases)) out[name] = await fn();
|
||||||
|
console.log(JSON.stringify(out));
|
||||||
|
""".replace("MODULE_PATH", _MODULE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def results():
|
||||||
|
if not _HAS_NODE:
|
||||||
|
pytest.skip("node is not installed")
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module", "-e", _HARNESS],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, f"node harness failed:\n{proc.stderr}"
|
||||||
|
return json.loads(proc.stdout.strip().splitlines()[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_exists():
|
||||||
|
assert _MODULE.is_file(), f"missing {_MODULE}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_is_revealed_one_paint_after_wiring(results):
|
||||||
|
r = results["reveal_waits_one_paint_then_keeps_node"]
|
||||||
|
assert r["beforePaint"]["revealed"] is False, "revealed before any frame ran"
|
||||||
|
assert r["afterOneFrame"]["revealed"] is False, "revealed before the paint committed"
|
||||||
|
assert r["afterPaint"] == {
|
||||||
|
"revealed": True,
|
||||||
|
"opacity": "0",
|
||||||
|
"pointerEvents": "none",
|
||||||
|
"ariaHidden": "true",
|
||||||
|
"removed": False,
|
||||||
|
}
|
||||||
|
assert r["waveStops"] == 1, "loader wave interval kept running after reveal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revealed_loader_stays_as_startup_sentinel(results):
|
||||||
|
# sessions.js / sidebar-layout.js read #app-loader as "startup in progress".
|
||||||
|
r = results["reveal_waits_one_paint_then_keeps_node"]
|
||||||
|
assert r["stillInDocument"] is True
|
||||||
|
assert r["afterPaint"]["removed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reveal_is_idempotent(results):
|
||||||
|
r = results["reveal_is_idempotent"]
|
||||||
|
assert r["waveStops"] == 1, "reveal ran its side effects more than once"
|
||||||
|
assert r["snapshot"]["revealed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_node_is_retired_after_the_fade(results):
|
||||||
|
r = results["remove_retires_the_loader_node"]
|
||||||
|
assert r["beforeTimers"]["revealed"] is True, "removal should hide immediately"
|
||||||
|
assert r["beforeTimers"]["removed"] is False, "removal should wait for the fade"
|
||||||
|
assert r["afterTimers"]["removed"] is True, "loader node outlived hydration"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_session_load_marks_the_sidebar_row(results):
|
||||||
|
r = results["failed_hydration_marks_sidebar_row"]
|
||||||
|
assert r["beforePaint"] == "Loading chats…", "failure written before the render frame"
|
||||||
|
assert r["afterPaint"] == "Chats unavailable"
|
||||||
|
assert r["loaderRemoved"] is True, "a failed load must still free the shell"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_session_success_never_shows_a_failure(results):
|
||||||
|
r = results["zero_session_success_shows_no_failure"]
|
||||||
|
assert r["rowRemoved"] is True
|
||||||
|
assert r["statusText"] == "Loading chats…", "false 'Chats unavailable' on empty success"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hydration_request_starts_synchronously(results):
|
||||||
|
r = results["hydration_starts_synchronously"]
|
||||||
|
assert r["startedBeforeAwait"] is True, "/api/sessions start was deferred a microtask"
|
||||||
|
|
||||||
|
|
||||||
|
def test_synchronous_load_failure_still_settles(results):
|
||||||
|
r = results["synchronous_load_failure_still_settles"]
|
||||||
|
assert r["threw"] is False, "a throwing loadSessions must not escape"
|
||||||
|
assert r["succeeded"] is False
|
||||||
|
assert r["opened"] == 0, "session-dependent route opened without session data"
|
||||||
|
assert r["ranAfterFailure"] is False, "failed startup left a stale route opener"
|
||||||
|
assert r["statusText"] == "Chats unavailable"
|
||||||
|
assert r["loaderRemoved"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_needing_no_session_data_opens_before_hydration(results):
|
||||||
|
r = results["route_without_session_data_opens_before_hydration"]
|
||||||
|
assert r["ranEarly"] is True, "/notes waited on /api/sessions it does not read"
|
||||||
|
assert r["openedAfterEarly"] == 1
|
||||||
|
assert r["ranAgain"] is False, "route opener fired twice"
|
||||||
|
assert r["opened"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_needing_session_data_waits_for_hydration(results):
|
||||||
|
r = results["route_with_session_data_waits_for_hydration"]
|
||||||
|
assert r["ranEarly"] is False, "/email opened before the session list was there"
|
||||||
|
assert r["openedAfterEarly"] == 0
|
||||||
|
assert r["openedAfterHydration"] == 1
|
||||||
|
assert r["succeeded"] is True
|
||||||
|
assert r["needsSessions"] == [True, False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_session_module_still_settles_without_opening_data_route(results):
|
||||||
|
r = results["missing_session_module_keeps_route_deferred"]
|
||||||
|
assert r["succeeded"] is False
|
||||||
|
assert r["opened"] == 0, "route opened without the session module it depends on"
|
||||||
|
assert r["ranAfterFailure"] is False, "missing module left a stale route opener"
|
||||||
|
assert r["statusText"] == "Chats unavailable"
|
||||||
|
assert r["loaderRemoved"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_throwing_route_opener_is_contained(results):
|
||||||
|
r = results["throwing_route_opener_is_contained"]
|
||||||
|
assert r["threw"] is False
|
||||||
|
assert r["ran"] is True
|
||||||
|
assert r["ranAgain"] is False, "a failed opener must not be retried"
|
||||||
Reference in New Issue
Block a user