Compare commits

..
7 Commits
Author SHA1 Message Date
Boody 1fef4929cf Merge pull request #5920 from adabarbulescu/fix/windows-workspace-access
fix(agent): use Git Bash for Windows workspace shell
2026-08-11 03:50:03 +03:00
RaresKeYandLéo 651bf714de perf(chat): batch live thinking rendering and bound timer updates (#5931)
* perf(chat): batch live thinking DOM updates

* test(chat): cover live thinking scheduler lifecycle

* fix(chat): guard background stop-state, restore live thinking text, drop source-text tests

- _closeOpenThinkingMarkup no longer overwrites currentAccumulated for
  backgrounded streams. It now mirrors the guard the delta path already uses
  (`if (!_isBg) currentAccumulated = accumulated`). Without it a backgrounded
  stream's text is written into the foreground session's stop-state, which
  abortCurrentRequest and detachCurrentStream then put in the wrong bubble.

- Split _extractLiveThinkingText into _liveThinkingText (strip every think tag)
  and _closedThinkingText (via extractThinkingBlocks). Slicing from the first
  <think> to the first </think> pinned the live box to "The" for the rest of the
  stream on the `<think>The</think>` + untagged-thinking pattern that the
  hasUnclosedThink detection deliberately keeps streaming through.

- The background transition now flushes with rich:true, so a stream that
  backgrounds mid-thinking isn't left as pre-wrap plain text permanently.

- Move the throttle to static/js/liveThinkingThrottle.js and import it. The
  .mjs suite imports the module instead of slicing it out of chat.js with
  vm.runInNewContext and marker comments.

- Replace the source-text assertions in tests/test_live_thinking_scheduler_js.py
  with behavioral coverage, per tests/TESTING_STANDARD.md. The .mjs suite grows
  from 3 to 6 cases.

- Collapse the duplicated tool_start/agent_step finalizers into one
  _endLiveThinkingSection().

* fix(chat): hoist thinking teardown out of the try block so catch can reach it

In an ES module a function declared inside `try { }` is scoped to that block,
and `catch` is a sibling scope rather than a nested one. _closeOpenThinkingMarkup
was declared inside the try and called from catch, so the call threw
ReferenceError and killed the rest of the error path: the stream never
finalized and the thinking block was never torn down.

Declare _closeOpenThinkingMarkup and a new _endThinkingOnTerminalPath next to
the existing _flushLiveThinking / _cancelLiveThinkingWork outer lets and assign
them inside the try, which is the pattern those two already use for exactly
this reason.

Verified against a live stream in a browser: before, clicking stop mid-thinking
logged "_closeOpenThinkingMarkup is not defined" and left no finalized thinking
section; after, the block collapses to "View thinking process" correctly.

* perf(chat): extract live thinking at commit cadence

* fix(chat): bound live thinking work

* test(chat): update stream invariant assertions

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 20:11:48 +01:00
RaresKeYandLéo d449a9d431 fix(history): defer full transcript hydration to model sends (#5929)
* fix(history): defer full hydration to model sends

* fix(session): key hydration on real rows, fork through get_session

Two regressions from the display/model-context split, both reproducible
against dev.

The hydration gate compared the cached transcript against the
denormalized sessions.message_count column. That column drifts in normal
operation — _persist_message swallows a failed insert while add_message
has already appended in memory, so the next successful persist writes
rows+1 — and _db_to_session re-read the same column after each reload, so
the shortfall never closed. Every send, edit, delete and truncate on a
warm session re-selected the whole message table: the cost this change
set out to remove, relocated onto the hot path. The other direction was
just as bad — a persist for an uncached session writes message_count = 0,
and a stale-low counter with a partly filled cache meant no hydration at
all and a silently truncated transcript for the model.

sync_session_metadata now reconciles message_count against COUNT(*) on
chat_messages (one indexed count inside the connection it already opens),
and _db_to_session trusts the rows it just loaded. A hydrate always
closes the gap, so the next read is a cache hit.

fork_session read session_manager.sessions directly and never hydrated.
keep_count indexes into source.history, and display pagination no longer
fills that cache, so forking after a restart returned HTTP 200 with an
empty conversation and no error surfaced. It goes through get_session
now.

_hydrate_session_history_from_db is gone with its helper: get_session is
the hydration seam, and rebuilding session.history from raw rows in the
display fallback overwrote the parsed multimodal content and the _db_id
edit/delete keys that had just been set.

Tests drive a real SessionManager over a temp DB instead of a stub that
only proved the stub hydrates — both drift directions, the send path
warm and cold, and a fork taken after a restart. All five fail without
this change. The brittle SQL-text assertions are dropped; the page
bounds are already proven by the response body.

* fix(history): route pagination through canonical handler

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 19:39:21 +01:00
RaresKeYandLéo dbeed4b63f perf(ui): stop session loading from blocking shell (#5927)
* perf(ui): stop session loading from blocking shell

* fix(startup): open routes on their own data, retire the loader for good

Follow-up to review on #5927.

- Route openers are now classified by the data they actually read. Only
  /email touches the hydrated session list (its new-chat path falls back to
  the most recent session's model when no default chat is set), so every
  other route opens as soon as module wiring completes instead of queueing
  behind /api/sessions. This is the deferred-route half of #5926, which the
  first pass left unimplemented.
- index.html's 5s fallback removes the loader node again. Leaving it in the
  DOM indefinitely kept _shouldPreserveStartupComposer true forever on a
  hung /api/sessions, so the composer stopped clearing on session switch.
- A missing session module settles hydration instead of leaving the sidebar
  on "Loading chats…" and dropping the user's route on the floor.
- Startup sequencing moved to static/js/startupShell.js so it can be run by
  tests. The source-text assertions in test_startup_shell_session_loading.py
  are replaced by node-driven behavioural tests, per tests/TESTING_STANDARD.md.
- Reverted the unrequested loader a11y rework, removed the duplicated inert
  writes (the module stops the wave interval through a callback), and moved
  the bootstrap row's inline styles into .session-list-bootstrap.

* fix: preserve session bootstrap failure state

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 19:37:21 +01:00
RaresKeY 96aca52094 perf(email): make library prewarm idle and bounded (#5925)
* perf(email): make library prewarm idle and bounded

* fix(email): preserve idle prewarm and prioritize foreground

* fix(email): retry interrupted idle prewarm safely
2026-08-10 19:14:10 +01:00
RaresKeYandLéo 8f2f483725 fix(email): make unread opens one authoritative IMAP operation (#5923)
* fix(email): mark opened messages seen in one IMAP operation

* fix(email): collapse unread opens and ignore stale responses

* fix(email): send seen flags as an IMAP flag list

Wrap the authoritative \\Seen STORE operand in parentheses so strict IMAP servers such as GreenMail accept both cache-miss and cached-open transitions. Tighten the focused fake IMAP contract to reject the previously emitted bare flag atom.

* fix(email): guard stale authoritative opens

* fix(email): report a failed \Seen instead of withholding the message

The authoritative-open contract made a failed STORE fatal to the read: the
cold path raised after the body was already fetched and parsed, and the
cached path discarded an in-memory message to return
{"error": "Failed to mark email read"}. A transient IMAP failure therefore
turned a readable message into one that could not be opened at all.

Being authoritative should mean the reported flag state is truthful, not
that the body is withheld. The read now always returns the message and
carries mark_seen_failed so the client can roll its optimistic unread
marker back:

- _read_email_sync logs and reports a rejected STORE rather than raising,
  and only writes the local index/list-cache transition when the provider
  accepted it, so local state cannot drift ahead of the mailbox.
- A mailbox that refuses a read-write SELECT (shared archives, some
  provider folders) falls back to a read-only selection and reports the
  flag failure instead of failing the open.
- The route strips mark_seen_failed before caching, so a one-off failure is
  never replayed to later readers.
- mark_seen now defaults to False on _read_email_sync. It was inert before
  this branch and now mutates provider state; the one caller that wants it
  off already passes it explicitly.

emailInbox and emailLibrary keep the message rendered when mark_seen_failed
is set and restore the unread state, rather than showing a failed reader.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 18:45:34 +01:00
adabarbulescu 48cf08328f fix(agent): use Git Bash for Windows workspace shell 2026-08-07 23:46:48 +03:00
28 changed files with 4395 additions and 545 deletions
+41 -12
View File
@@ -194,7 +194,12 @@ class SessionManager:
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
# ------------------------------------------------------------------
@@ -398,30 +403,50 @@ class SessionManager:
# ------------------------------------------------------------------
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
first read here hydrates them with the message rows.
Sessions seeded by ``load_sessions`` start with empty history, and a
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:
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
# 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)
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
self._touch_session(session_id)
return self.sessions[session_id]
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)
if session is None:
return False
@@ -444,7 +469,11 @@ class SessionManager:
session.archived = db_session.archived
session.owner = getattr(db_session, "owner", None)
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
except Exception as e:
logger.error(f"Error syncing session metadata {session_id}: {e}")
+95 -36
View File
@@ -2861,13 +2861,22 @@ def setup_email_routes():
return indexed_response
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.
The normal reader path fetches the headers plus a bounded body prefix.
That avoids downloading multi-megabyte attachments just to open a
message. Full-message fetch remains available for flows that need
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
_t0 = _t.monotonic()
@@ -2875,9 +2884,28 @@ def setup_email_routes():
preview_bytes = 384 * 1024
_t_select = 0.0
_t_fetch = 0.0
mark_seen_failed = False
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder), readonly=True)
# 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)
mark_seen_failed = True
_t_select = _t.monotonic() - _t0
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)
@@ -2903,22 +2931,44 @@ def setup_email_routes():
header_part = msg_data[0][1] or b""
raw = header_part + b"\r\n" + text_part
msg = email_mod.message_from_bytes(raw)
# 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)
subject = _decode_header(msg.get("Subject", "(no subject)"))
sender = _decode_header(msg.get("From", "unknown"))
to = _decode_header(msg.get("To", ""))
cc = _decode_header(msg.get("Cc", ""))
date_str = msg.get("Date", "")
message_id = msg.get("Message-ID", "")
in_reply_to = msg.get("In-Reply-To", "")
references = msg.get("References", "")
body = _extract_text(msg)
body_html = _extract_html(msg)
subject = _decode_header(msg.get("Subject", "(no subject)"))
sender = _decode_header(msg.get("From", "unknown"))
to = _decode_header(msg.get("To", ""))
cc = _decode_header(msg.get("Cc", ""))
date_str = msg.get("Date", "")
message_id = msg.get("Message-ID", "")
in_reply_to = msg.get("In-Reply-To", "")
references = msg.get("References", "")
body = _extract_text(msg)
body_html = _extract_html(msg)
sender_name, sender_addr = email.utils.parseaddr(sender)
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 [])
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)
sender_name, sender_addr = email.utils.parseaddr(sender)
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 [])
related_attachments = []
if full and not _has_visible_attachments(msg):
related_attachments = _related_thread_attachments_sync(
@@ -3039,20 +3089,29 @@ def setup_email_routes():
"boundaries": cached_boundaries,
"thread_turns": cached_turns,
"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:
logger.error(f"Failed to read email {uid}: {e}")
return {"error": "Mail operation failed"}
def _mark_email_seen_sync(uid, folder, account_id, owner):
"""Synchronously mark a cached email seen and report success."""
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder))
conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Seen")
conn.select(_q(folder), readonly=False)
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)
_update_list_cache_seen(account_id, folder, uid, True)
return True
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}")
async def read_email_by_uid(
@@ -3078,32 +3137,32 @@ def setup_email_routes():
if cached.get("attachment_version") != EMAIL_READ_ATTACHMENT_VERSION:
cached = None
if cached is not None:
if mark_seen:
try:
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
except RuntimeError:
pass
# A cache hit already holds a complete, valid message. Await the
# STORE so the response reports the real flag state, but never let
# a failed STORE withhold a body we are holding in memory.
if mark_seen and not await _asyncio.to_thread(
_mark_email_seen_sync, uid, folder, account_id, owner
):
return {**cached, "mark_seen_failed": True}
return cached
if not full:
persisted = _email_preview_cache_get(owner, account_id, folder, uid)
if persisted and persisted.get("attachment_version") == EMAIL_READ_ATTACHMENT_VERSION:
_read_cache_put(ck, persisted)
if mark_seen:
try:
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
except RuntimeError:
pass
if mark_seen and not await _asyncio.to_thread(
_mark_email_seen_sync, uid, folder, account_id, owner
):
return {**persisted, "mark_seen_failed": True}
return persisted
result = await _asyncio.to_thread(_read_email_sync, uid, folder, account_id, owner, mark_seen, full)
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:
_email_preview_cache_put(owner, account_id, folder, uid, result)
if mark_seen:
try:
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
except RuntimeError:
pass
_email_preview_cache_put(owner, account_id, folder, uid, cacheable)
return result
def _schedule_recent_email_warm(emails: list, folder: str, account_id: str | None, owner: str):
+16 -58
View File
@@ -137,44 +137,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
entry["metadata"] = meta
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}")
async def get_session_history(
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 = 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 = (
db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id)
@@ -206,14 +170,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
.limit(page_limit)
.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 = [
entry for entry in (_db_history_entry(m) for m in rows)
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"]
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:
db = SessionLocal()
try:
@@ -268,17 +227,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
.order_by(DbChatMessage.timestamp)
.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.
history_dict = [
m for m in db_history
if not (m.get("metadata") or {}).get("hidden")
entry for entry in (_db_history_entry(m) for m in db_messages)
if not (entry.get("metadata") or {}).get("hidden")
]
except Exception as 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()
keep_count = body.get("keep_count", 0)
# Get the source session
source = session_manager.sessions.get(session_id)
# Get the source session. keep_count indexes into source.history,
# 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:
raise HTTPException(404, "Session not found")
-9
View File
@@ -801,15 +801,6 @@ def setup_session_routes(
finally:
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")
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
"""Export conversation history as a downloadable file.
+36 -8
View File
@@ -6,6 +6,7 @@ import sys
import time
import collections
from typing import Optional, Callable, Awaitable, Tuple, Dict
from core.platform_compat import IS_WINDOWS, find_bash
from src.constants import MAX_OUTPUT_CHARS
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
@@ -16,6 +17,27 @@ PROGRESS_TAIL_LINES = 12
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:
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
return f"ody-agent-{raw[:80] or 'default'}"
@@ -280,7 +302,10 @@ class BashTool:
progress_cb = ctx.get("progress_cb")
_subproc_env = ctx.get("subproc_env")
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(
content,
session_id=str(session_id),
@@ -307,13 +332,16 @@ class BashTool:
"tmux_session": _tmux_session_name(str(session_id)),
}
proc = await asyncio.create_subprocess_shell(
content,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
)
try:
proc = await _create_bash_subprocess(
content,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
)
except RuntimeError as e:
return {"error": f"bash: {e}", "exit_code": 1}
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc,
timeout=DEFAULT_BASH_TIMEOUT,
+28 -19
View File
@@ -15,6 +15,12 @@ import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import {
revealApplicationShellAfterPaint,
runDeferredRouteOpener,
deferRouteOpener,
settleSessionHydration
} from './js/startupShell.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
import sessionModule from './js/sessions.js';
@@ -1217,12 +1223,13 @@ function initializeEventListeners() {
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
};
const _opener = _routeOpen[urlPath];
// Defer the opener — at this point in init, the modules whose handlers
// we trigger (#rail-new-session click handler, the email-section header
// click handler in emailInbox, sessionModule's loaded session list) are
// still being wired up further down in this same function. Stash the
// opener so it runs from sessionModule.loadSessions().finally() below.
if (_opener) window._odysseusRouteOpener = _opener;
// Defer the opener — at this point in init, the modules whose handlers we
// trigger (#rail-new-session click handler, the email-section header click
// handler in emailInbox, sessionModule) are still being wired up further
// down in this same function. startupShell decides when it can run: as soon
// as wiring completes, or — for the routes that read the session list —
// once /api/sessions has settled.
deferRouteOpener(urlPath, _opener);
// Archive browser tool button
const toolLibraryBtn = el('tool-library-btn');
@@ -4315,6 +4322,10 @@ function startOdysseusApp() {
// Load initial data
presetsModule.loadPresets(uiModule.showError);
// Core wiring is complete for this turn — reveal the shell independently of
// the session-list request.
revealApplicationShellAfterPaint();
if (sessionModule) {
sessionModule.initDependencies({
API_BASE: API_BASE,
@@ -4326,21 +4337,19 @@ function startOdysseusApp() {
scrollHistory: uiModule.scrollHistoryInstant
});
// Load sessions first (critical path) — remove loader when done
sessionModule.loadSessions()
.catch(e => console.warn('loadSessions error:', e))
.finally(() => {
const loader = document.getElementById('app-loader');
if (loader) { loader.style.opacity = '0'; setTimeout(() => loader.remove(), 300); }
// Fire any URL route opener now that sessions + module wiring are
// ready. Deferred from up top of init for exactly this reason.
if (window._odysseusRouteOpener) {
try { window._odysseusRouteOpener(); } catch (_) {}
window._odysseusRouteOpener = null;
}
});
// sessionModule is now wired, so every route opener has the modules it
// drives. The ones that read no session data open here rather than
// queueing behind /api/sessions.
runDeferredRouteOpener();
// The shell is already usable at this point; session hydration is
// sidebar-local and settles on its own schedule.
settleSessionHydration(() => sessionModule.loadSessions());
} else {
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) => {
+17 -5
View File
@@ -248,8 +248,8 @@
}, { once: true });
})();
</script>
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
<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/ui.js">
<link rel="modulepreload" href="/static/js/sessions.js">
@@ -286,7 +286,13 @@
if(!document.getElementById('app-loader')){clearInterval(iv);return}
render();
},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>
<!-- 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>
</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>
<!-- Hidden dropdown for session actions -->
<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/settings.js?v=20260723compareicon1"></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/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
+1
View File
@@ -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. |
| **`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`. |
| **`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`. |
| **`slashAutocomplete.js`** | Composer autocomplete popup for `/` commands. |
| **`composerArrowUpRecall.js`** | Recall last user message with `↑` on an empty composer. |
+513 -230
View File
File diff suppressed because it is too large Load Diff
+49 -10
View File
@@ -149,6 +149,7 @@ let _loading = false;
let _expanded = false;
let _docModule = null;
let _listSpinner = null;
let _openEmailRequestSeq = 0;
let _senderFilter = null; // email address (lowercased) to filter by, or null
let _senderFilterLabel = null; // display label for the active filter chip
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
@@ -187,7 +188,7 @@ export function init(documentModule) {
} catch (_) {}
if (opts.compose) { _composeNew(); return; }
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;
}
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 wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
// 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;
if (!data) {
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();
}
if (!isCurrentOpen()) return;
if (data.error) {
console.error('Failed to read email:', data.error);
return;
@@ -808,7 +824,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
message_id: _fallback(data.message_id, em.message_id),
};
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) {
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
} else {
@@ -834,7 +850,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
session_id: currentSessionId,
message_id: data.message_id || '',
uid: String(em.uid || ''),
folder: _currentFolder,
folder: folderAtStart,
account_id: activeReplyAccount,
fast: true,
user_hint: (noteHint || '').trim() || undefined,
@@ -842,6 +858,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
});
const result = await res.json();
if (draftToastTimer) clearTimeout(draftToastTimer);
if (!isCurrentOpen()) return;
if (result.success && result.reply) {
aiSuggestedBody = _cleanAiReplyText(result.reply);
} else {
@@ -855,6 +872,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}
} catch (e) {
if (draftToastTimer) clearTimeout(draftToastTimer);
if (!isCurrentOpen()) return;
console.error('AI reply generation failed:', e);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
return;
@@ -862,8 +880,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}
}
em.is_read = true;
if (itemEl) itemEl.classList.remove('email-unread');
if (!isCurrentOpen()) return;
// 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
// 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 += `\nReferences: ${data.references ? data.references + ' ' + data.message_id : data.message_id}`;
content += `\nX-Source-UID: ${em.uid}`;
content += `\nX-Source-Folder: ${_currentFolder}`;
content += `\nX-Source-Folder: ${folderAtStart}`;
if (data.attachments && data.attachments.length > 0) {
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
content += `\nX-Attachments: ${attStr}`;
@@ -980,21 +1002,27 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
// and block Send on long threads.
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
? _docModule.findEmailDocId(em.uid, _currentFolder)
? _docModule.findEmailDocId(em.uid, folderAtStart)
: null;
if (existingDocId) {
if (!_docModule.isPanelOpen()) _docModule.openPanel();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
if (!isCurrentOpen()) return;
await _docModule.loadDocument(existingDocId);
if (!isCurrentOpen()) return;
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
if (!isCurrentOpen()) return;
}
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
if (!isCurrentOpen()) return;
}
_bringEmailReplyDraftToFrontOnMobile();
} else {
if (!isCurrentOpen()) return;
let activeSid = await _createEmailChat(data, { forceNew: true });
if (!isCurrentOpen()) return;
if (!activeSid) {
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(() => {});
@@ -1012,13 +1040,20 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}),
});
let docRes = await createReplyDoc(activeSid);
if (!isCurrentOpen()) return;
if (docRes.status === 404) {
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
if (!isCurrentOpen()) return;
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) {
const errText = await docRes.text();
if (!isCurrentOpen()) return;
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
// uiModule isn't statically imported here — use the dynamic
// 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;
}
const doc = await docRes.json();
if (!isCurrentOpen()) return;
if (doc.id) {
const wasOpen = _docModule.isPanelOpen();
if (!wasOpen) _docModule.openPanel();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
if (!isCurrentOpen()) return;
// 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
// 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);
} else {
await _docModule.loadDocument(doc.id);
if (!isCurrentOpen()) return;
}
_bringEmailReplyDraftToFrontOnMobile();
}
}
}
} catch (e) {
if (!isCurrentOpen()) return;
console.error('Failed to open email:', e);
// Surface the failure so a silent throw in the reply flow doesn't
// look like "nothing happened". Dynamic import — uiModule isn't a
+420 -153
View File
@@ -30,6 +30,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _emailUnreadChipClickWired = false;
let _libLoadSeq = 0;
let _emailMailboxGeneration = 0;
let _emailCardOpenSeq = 0;
let _emailReadMutationSeq = 0;
const _emailReadMutations = new Map();
let _libFolderSeq = 0;
let _libSearchSeq = 0;
let _libSearchHadResults = false;
@@ -837,14 +841,41 @@ document.addEventListener('keydown', (e) => {
e.stopImmediatePropagation?.();
}, 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;
const uidStr = String(uid);
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;
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);
const titleRow = card.querySelector('.email-card-titlerow');
if (read) {
@@ -1762,11 +1793,18 @@ function _rememberedEmailAccountId() {
// results and __scheduled__ are deliberately not cached.
const _libListCache = new Map();
const _LIB_CACHE_MAX = 24;
const _LIB_INITIAL_PAGE_SIZE = 100;
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
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 _libPrewarmResolve = null;
let _libPrewarmAbortController = null;
let _libPrewarmDetachPriorityListeners = null;
let _libPrewarmGeneration = 0;
let _libLastPrewarmAt = 0;
let _libUnreadPrewarmKey = '';
let _libUnreadPrewarmAt = 0;
@@ -1908,6 +1946,7 @@ function _resetEmailListForFreshLoad({ useCache = true } = {}) {
_exitEmailReaderModeForList();
_resetBulkSelectionForContextChange();
state._libOffset = 0;
_emailMailboxGeneration += 1;
_libLoadSeq += 1;
const ck = _libCacheKey();
const cached = useCache ? _libCacheGet(ck) : null;
@@ -2076,162 +2115,319 @@ function _isChatInteractionBusy() {
}
}
function _loadEmailsWhenChatIdle({ delay = 50, retries = 180, options = {} } = {}) {
const run = () => {
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
if (_isChatInteractionBusy() && retries > 0) {
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
function _canRunEmailPrewarm() {
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
if (document.visibilityState && document.visibilityState !== 'visible') return false;
return !_isChatInteractionBusy();
}
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;
}
_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 } = {}) {
if (_libPrewarmTimer || _libPrewarmPromise) return;
if (_libPrewarmPromise) return _libPrewarmPromise;
const elapsed = Date.now() - _libLastPrewarmAt;
if (elapsed >= 0 && elapsed < 5 * 60 * 1000) return;
_libPrewarmTimer = setTimeout(() => {
_libPrewarmTimer = null;
_libPrewarmPromise = _prewarmEmailViews()
.catch(() => {})
.finally(() => { _libPrewarmPromise = null; });
}, Math.max(0, Number(delay) || 0));
if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false);
return _scheduleEmailPrewarm(_prewarmEmailViews, { delay });
}
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;
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 {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (!accountsRes.ok) return;
const accountsData = await accountsRes.json().catch(() => ({}));
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_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();
if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) {
try {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (accountsRes.ok) {
const accountsData = await accountsRes.json().catch(() => ({}));
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
}
}
} catch (err) {
if (err?.name === 'AbortError') return null;
}
} catch (_) {}
}
const accountId = _chooseEmailPrewarmAccountId(state._libAccounts);
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (!accountId) return null;
if (accountId && state._libAccountId !== accountId) {
state._libAccountId = accountId;
_publishActiveAccount();
}
return accountId;
}
export async function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
if (state._libOpen) return;
await _ensureEmailAccountsForPrewarm();
if (state._libOpen) return;
const accountId = state._libAccountId || '';
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 key = `${accountId}|${maxUid || 0}|${n}`;
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return;
_libUnreadPrewarmKey = key;
_libUnreadPrewarmAt = Date.now();
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true;
try {
const folder = 'INBOX';
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: n,
offset: 0,
filter: 'unread',
account_id: accountId || undefined,
}), { credentials: 'same-origin' });
if (state._libOpen) return;
if (!res.ok) return;
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: n,
offset: 0,
filter: 'unread',
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
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 || {};
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
emails: data.emails,
total: data.total || data.emails.length,
sync,
});
} catch (_) {}
_libUnreadPrewarmKey = key;
_libUnreadPrewarmAt = Date.now();
return true;
} catch (_) {
return false;
}
}
function _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function _prewarmEmailViews() {
if (state._libOpen) return;
_libLastPrewarmAt = Date.now();
async function _prewarmEmailViews({ signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
_setEmailSyncStatus({ warming: true });
const folder = 'INBOX';
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 {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (accountsRes.ok) {
const accountsData = await accountsRes.json().catch(() => ({}));
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
}
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
const ck = _libCacheKeyFor(accountId, folder, filter, false);
if (_libCacheGet(ck)) {
_libLastPrewarmAt = Date.now();
return true;
}
} 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);
if (_libCacheGet(ck)) continue;
await fetch(emailApiUrl('/api/email/folders', { account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
await fetch(emailApiUrl('/api/email/unread-state', { folder, account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: 100,
offset: 0,
filter,
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
});
if (res.ok) {
const data = await res.json().catch(() => null);
if (data && !data.error) {
const sync = data.sync || {};
_libCachePut(ck, {
emails: data.emails || [],
total: data.total || 0,
sync,
});
_setEmailSyncStatus({
updatedAt: sync.updated_at || new Date().toISOString(),
source: sync.source || '',
warming: true,
});
}
}
await _sleep(900);
}
// 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', {
folder,
limit: _LIB_INITIAL_PAGE_SIZE,
offset: 0,
filter,
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
const data = await res.json().catch(() => null);
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
if (!data || data.error || !Array.isArray(data.emails)) return false;
const sync = data.sync || {};
_libCachePut(ck, {
emails: data.emails,
total: data.total || 0,
sync,
});
_libLastPrewarmAt = Date.now();
_setEmailSyncStatus({
updatedAt: sync.updated_at || new Date().toISOString(),
source: sync.source || '',
warming: true,
});
return true;
} catch (_) {
return false;
} finally {
_setEmailSyncStatus({ warming: false });
}
@@ -2286,16 +2482,34 @@ function _publishActiveAccount() {
export function initEmailLibrary(config) {
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 openEmailLibrary(opts = {}) {
if (_libPrewarmTimer) {
clearTimeout(_libPrewarmTimer);
_libPrewarmTimer = null;
}
// Foreground email always wins: cancel a delayed/idle callback and abort the
// one optional request if it has already started. Generation checks make a
// non-abortable response harmless if it races this transition.
_cancelEmailPrewarm();
// Force-clean any stale state from previous attempts
const existing = document.getElementById('email-lib-modal');
if (existing) existing.remove();
@@ -2303,6 +2517,7 @@ export function openEmailLibrary(opts = {}) {
document.removeEventListener('keydown', state._libEscHandler, true);
state._libEscHandler = null;
}
_emailMailboxGeneration += 1;
state._libOpen = true;
// On mobile the sidebar overlays content — close it so the email view isn't
// opened behind it (same pattern as session-switch/delete).
@@ -2926,7 +3141,7 @@ export function openEmailLibrary(opts = {}) {
}
const fastAccountAtOpen = state._libAccountId || '';
if (fastAccountAtOpen) {
_loadEmailsWhenChatIdle({ delay: 0 });
_loadEmails({ useCache: true });
}
// If we already know the previous/default account, paint that inbox first
// from the durable index and validate accounts in parallel. Cold refreshes
@@ -2936,7 +3151,7 @@ export function openEmailLibrary(opts = {}) {
_loadFolders();
_loadEmailReminderBellVisibility();
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
_loadEmailsWhenChatIdle();
_loadEmails({ useCache: true });
}
})();
}
@@ -3121,6 +3336,7 @@ export async function openEmailLibrarySettings() {
}
export function closeEmailLibrary() {
_cancelEmailPrewarm();
const modal = document.getElementById('email-lib-modal');
if (modal) modal.remove();
if (_libSyncTicker) {
@@ -4554,7 +4770,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 450);
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,
});
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
// Refresh button passes `force: true` to add it back.
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();
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
if (data.error) throw new Error(data.error);
@@ -4836,6 +5052,8 @@ function _createCard(em) {
else if (!em.is_read) cls += ' email-card-unread';
card.className = cls;
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');
// Checkbox in select mode
@@ -5162,6 +5380,25 @@ async function _toggleCardPreview(card, em) {
// currently-selected folder for normal inbox cards.
const folderAtStart = (em && em.folder) || libraryFolderAtStart;
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 gridRect = grid?.getBoundingClientRect?.();
const modal = document.getElementById('email-lib-modal');
@@ -5186,6 +5423,30 @@ async function _toggleCardPreview(card, em) {
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
if (grid) {
grid.querySelectorAll('.email-card-expanded').forEach(c => {
@@ -5207,10 +5468,10 @@ async function _toggleCardPreview(card, em) {
requestAnimationFrame(() => {
try { card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
});
if (!em.is_read) {
_syncEmailReadState(em.uid, true);
fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' })
.catch(err => console.error('Failed to mark email read:', err));
if (!wasReadAtStart) {
// Keep the current optimistic visual update, but let the read request below
// own the provider-side \Seen transition. A failure restores unread state.
_syncEmailReadState(uidAtStart, true, readContext);
}
// Class hook on the modal so the header-hide / padding rules work on
// browsers without :has() support (Firefox mobile) — the :has() versions
@@ -5239,25 +5500,28 @@ async function _toggleCardPreview(card, em) {
} catch (_) {}
};
let authoritativeReadSucceeded = false;
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}`);
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) {
showFailedReader(`Failed to load email: ${data.error}`);
restoreUnreadState();
if (isCurrentOpen()) showFailedReader(`Failed to load email: ${data.error}`);
return;
}
// Mark as read locally
_syncEmailReadState(em.uid, true);
if (data.mark_seen_failed) {
// 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);
// Build the attachments wrap using the shared helper so the signature-
@@ -5439,7 +5703,10 @@ async function _toggleCardPreview(card, em) {
// Always stop bubbling so the card's click doesn't fire while reading.
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
} catch (e) {
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
if (!authoritativeReadSucceeded) restoreUnreadState();
if (isCurrentOpen()) {
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
}
}
}
+206
View File
@@ -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;
+20 -1
View File
@@ -1683,8 +1683,21 @@ export async function loadSessions() {
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
}
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();
}
if (!Array.isArray(fetched)) {
throw new Error('Session request returned an invalid response');
}
sessions = _normalizeSessionsList(fetched);
renderSessionList();
@@ -1807,9 +1820,15 @@ export async function loadSessions() {
_autoCreateInProgress = false;
}
}
return true;
} catch (error) {
console.error('Error in loadSessions:', error);
uiModule.showError('Failed to load sessions: ' + error.message);
// 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);
}
return false;
}
}
+153
View File
@@ -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);
});
}
+6
View File
@@ -38015,6 +38015,12 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label {
outline-offset: 2px;
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 {
padding: 10px 5px 3px;
}
+277
View File
@@ -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('&lt;think&gt;literal&lt;/think&gt;'),
'&lt;think&gt;literal&lt;/think&gt;',
);
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, ['']);
});
+128
View File
@@ -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"}}
+2 -1
View File
@@ -1,3 +1,4 @@
import re
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 _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 "_removeThinkingSpinner = () => {" in try_body
assert "function _renderStream()" not in try_body
+414
View File
@@ -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
+231
View File
@@ -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}
+278
View File
@@ -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 == []
+5
View File
@@ -34,6 +34,11 @@ class _FakeSessionManager:
self.sessions = {"src-id": source}
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,
model=None, rag=False, owner=None):
self.created = _FakeSession(name=name, owner=owner)
+3 -3
View File
@@ -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).
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
hidden messages leaked to the client on DB-served sessions. The rebuilt
in-memory ``session.history`` must still keep them, though, so only the response
is filtered.
hidden messages leaked to the client on DB-served sessions. Hydration of
``session.history`` belongs to ``get_session``; this fallback only shapes the
response, so only the response is filtered.
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
@@ -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)
+29
View File
@@ -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}"
)
+356
View File
@@ -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"]
+377
View File
@@ -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"