Merge origin/master into PR #5271

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frank SongandPaperclip committed 2026-07-01 01:42:44 +08:00
commit 2ffb8ab470
21 files changed
+727 -14

No files matched your search

+5
View File
@@ -35,6 +35,11 @@
### Fixed
- **Opt-in busy composer placeholder hint.** A new default-off setting swaps the composer placeholder to a busy-mode-specific hint (queue / steer / interrupt) when the agent is running, deferring to the compression placeholder and any user draft, and restoring on idle. Text-only (no layout change), XSS-safe, localized across all 14 locales. Thanks @rodboev. (#5161, #5144)
- **A server-initiated turn that finished during an SSE gap now renders correctly on a visible-tab wake.** When a tab regained focus after the SSE stream had a gap, a self-heal reload could clobber a concurrent same-session `server_turn_started`, losing live-render ownership. The active stream id is now re-read after the awaited message load and scoped to the same session, so a concurrent same-session stream is honored by the attach/idle decision. The original SSE-gap self-heal (metadata-only server-ahead, in-place replace, no jump) is intact. Thanks @allenliang2022. (#5248)
- **The latest assistant response now carries a single accessible landmark, only once it has settled.** The "Latest Hermes response" landmark is no longer applied to an actively-streaming turn (only the latest settled turn gets it), giving screen-reader users one stable, correctly-placed landmark. Thanks @sheldon-im. (#5207)
- **A just-settled Compact Worklog now stays open for an unpinned reader instead of jumping.** When a streaming turn settled, the worklog could collapse-jump for a reader who wasn't pinned to the bottom; the disarm path now collapse-renders consistently for both pinned and unpinned readers (one frame, height-stable swap), so there's no post-settle scroll jump on mobile. No cache leak, and no #4970 regression. Thanks @allenliang2022. (#5260)
- **The sidebar source filter now offers Webhooks parity with Cron Jobs.** Adds a `show_webhook_sessions` toggle threaded through the session-list cache key + builder and a localized checkbox, so webhook-origin sessions can be filtered like cron sessions. Preserves the archived-row paging behavior. Thanks @MaudeBot. (#4957, closes #4956)
+65
View File
@@ -288,6 +288,71 @@ def active_stream_id_for_session(session_id: str) -> Optional[str]:
return None
def persisted_message_count_for_session(session_id: str) -> Optional[int]:
"""Cheap, metadata-only persisted ``message_count`` for *session_id*, or None.
Companion to ``active_stream_id_for_session`` for the per-session SSE
on-subscribe self-heal. ``active_stream_id_for_session`` recovers a turn
that is live RIGHT NOW (replay ``server_turn_started``). But a
SERVER-initiated turn (self-wake / cron / restart hook) can start AND
finish entirely inside an SSE gap: the fire-and-forget
``server_turn_started`` reached no subscriber AND the run already cleared
from ``ACTIVE_RUNS`` by the time the tab reconnects, so the replay finds
nothing (returns None) and the tab's transcript stays stale until a hard
refresh — the reported visible-tab defect. To detect that case the handler
compares the freshly-(re)subscribed tab's last-known count against this
persisted count; a server that is AHEAD means a turn landed during the gap.
Reads via ``metadata_only=True`` so it never parses the full transcript
(this runs on every per-session SSE (re)connect). The persisted count is
written by ``Session.save`` as ``meta['message_count'] = len(messages)`` —
the SAME basis the frontend's ``S.session.message_count`` is built from —
so the comparison is apples-to-apples. Returns None when the count is
unknown (legacy sidecars without a persisted count); the caller treats
None as "cannot tell, do nothing", never as a trigger.
"""
try:
from api.models import get_session
s = get_session(session_id, metadata_only=True)
count = getattr(s, "_metadata_message_count", None)
if count is None:
msgs = getattr(s, "messages", None)
count = len(msgs) if isinstance(msgs, list) and msgs else None
return int(count) if count is not None else None
except Exception:
logger.debug(
"persisted_message_count_for_session lookup failed for %s",
session_id,
exc_info=True,
)
return None
def should_emit_session_updated(
subscriber_known_count: Optional[int],
persisted_count: Optional[int],
) -> bool:
"""Gate for the per-session SSE "finished during the gap" self-heal emit.
Single source of truth shared by the SSE handler and its tests so the two
cannot drift (the handler MUST call this, not inline the comparison).
Emit a ``session-updated`` frame ONLY when:
* the (re)subscribing tab reported a last-known count (``?known_count``),
AND
* the persisted server-side count is known, AND
* the server is STRICTLY ahead (a turn landed during the gap).
A missing known count (tab didn't report), an unknown persisted count
(legacy sidecar), or an equal/behind count all return False — never a
spurious reload.
"""
if subscriber_known_count is None:
return False
if persisted_count is None:
return False
return persisted_count > subscriber_known_count
def _reaper_loop() -> None:
logger.info("SessionChannel reaper thread started")
while not _REAPER_STOP.is_set():
+2
View File
@@ -7795,6 +7795,7 @@ _SETTINGS_DEFAULTS = {
"show_token_usage": False, # show input/output token badge below assistant messages
"show_quota_chip": False, # show ambient provider quota chip in composer footer (default off; wide desktop only when enabled, see style.css @media)
"show_conversation_outline": False, # show opt-in desktop jump-to-question outline panel
"show_busy_placeholder_hint": False, # opt-in busy composer placeholder hint
"hide_empty_state_suggestions": False, # hide the default new-chat suggestion buttons
"virtualize_transcript": False, # #4343: virtualize long (>80 msg) transcripts. EXPERIMENTAL, opt-IN (default OFF). Was opt-out/default-on in #4325 but caused scroll-up flicker on long sessions with tall tool-call rows (variable-height anchor oscillation) — flipped off for everyone in #4343; re-enabling requires an explicit opt-in (see virtualize_transcript_optin migration in load_settings).
"virtualize_transcript_optin": False, # #4343 migration marker: True only once the user explicitly enables virtualize_transcript AFTER the default-off flip. A stored virtualize_transcript=True WITHOUT this marker is a stale pre-flip value and is reset to False on load (force-off-for-everyone migration).
@@ -8040,6 +8041,7 @@ _SETTINGS_BOOL_KEYS = {
"show_token_usage",
"show_quota_chip",
"show_conversation_outline",
"show_busy_placeholder_hint",
"hide_empty_state_suggestions",
"virtualize_transcript",
"virtualize_transcript_optin",
+42
View File
@@ -16187,9 +16187,22 @@ def _handle_session_sse_stream(handler, parsed):
if not sid:
return bad(handler, "session_id is required")
# The (re)subscribing tab reports its last-known message_count via
# ?known_count=N so the on-subscribe self-heal can detect a server-initiated
# turn that started AND finished entirely inside this tab's SSE gap (see the
# "server-initiated turn finished during the gap" self-heal block below).
# Absent/blank/non-numeric => None ("tab didn't report", never triggers).
_known_count_raw = parse_qs(parsed.query).get("known_count", [""])[0]
try:
subscriber_known_count = int(_known_count_raw) if _known_count_raw != "" else None
except (TypeError, ValueError):
subscriber_known_count = None
from api.background_process import (
subscribe_to_session_channel,
active_stream_id_for_session,
persisted_message_count_for_session,
should_emit_session_updated,
)
# Atomic get-or-create + subscribe under SESSION_CHANNELS_LOCK. Doing these
@@ -16274,6 +16287,35 @@ def _handle_session_sse_stream(handler, parsed):
"source": "subscribe_recovery",
"recovered": True,
})
else:
# ── Server-initiated turn that FINISHED during the SSE gap ──
# The block above only heals a turn that is live RIGHT NOW. But
# a server-initiated turn (self-wake / cron / restart hook) can
# start AND finish entirely inside the gap: the fire-and-forget
# `server_turn_started` reached no subscriber, and by the time
# this tab reconnects the run has already cleared from
# ACTIVE_RUNS — so active_stream_id_for_session returns None and
# nothing above replays. The turn IS persisted, but this tab's
# transcript stays stale until a hard refresh (the reported
# visible-tab defect). Detect it by comparing the persisted
# message_count against what this (re)subscribing tab last knew
# (?known_count). If the server is AHEAD, emit a lightweight
# `session-updated` frame so the tab does an INCREMENTAL,
# swap-in-place message sync (frontend reuses #5189's
# keepStaleUntilLoaded loadSession path — NO clear+refetch, so
# the #5177/#5189 blank-gap jump is not reintroduced). Carries
# only counts (no transcript) to stay cheap. Skipped
# entirely when the tab didn't report a count or the persisted
# count is unknown (legacy sidecar) → never a spurious reload.
if subscriber_known_count is not None:
persisted_count = persisted_message_count_for_session(sid)
if should_emit_session_updated(subscriber_known_count, persisted_count):
_sse(handler, 'session-updated', {
"session_id": sid,
"message_count": persisted_count,
"known_count": subscriber_known_count,
"source": "subscribe_recovery",
})
except _CLIENT_DISCONNECT_ERRORS:
# Client vanished mid-recovery — re-raise so the outer handler
# treats it as a normal disconnect and the finally still cleans up.
+3
View File
@@ -2321,6 +2321,7 @@ function applyBotName(){
if(topbarTitle && (!S.session)) topbarTitle.textContent=name;
const msg=$('msg');
if(msg) msg.placeholder='Message '+name+'\u2026';
if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder();
}
const _COMPOSER_CONTROL_TOGGLE_DEFS=[
@@ -2454,6 +2455,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000,
};
window._busyInputMode=(s.busy_input_mode||'queue');
window._showBusyPlaceholderHint=!!s.show_busy_placeholder_hint;
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
window._autoScrollFollow=s.auto_scroll_follow!==false;
window._largeTextPasteAsAttachment=s.large_text_paste_as_attachment!==false;
@@ -2576,6 +2578,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
window._sidebarDensity='compact';
window._pinnedSessionsLimit=3;
window._busyInputMode='queue';
window._showBusyPlaceholderHint=false;
window._sessionEndlessScrollEnabled=false;
window._autoScrollFollow=true;
window._composerControlVisibility=_composerControlVisibilityFromSettings(null);
+70
View File
@@ -320,6 +320,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'Queue follow-up',
settings_busy_input_mode_interrupt: 'Interrupt current turn',
settings_busy_input_mode_steer: 'Steer (mid-turn correction)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge:'Skill',
slash_skill_desc:'Invoke this skill',
@@ -1963,6 +1968,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'Accoda follow-up',
settings_busy_input_mode_interrupt: 'Interrompi turno corrente',
settings_busy_input_mode_steer: 'Steer (correzione a metà turno)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge:'Skill',
slash_skill_desc:'Invoca questa skill',
@@ -3597,6 +3607,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'フォローアップをキュー',
settings_busy_input_mode_interrupt: '現在のターンを中断',
settings_busy_input_mode_steer: 'ステア (ターン中の修正)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge:'スキル',
slash_skill_desc:'このスキルを呼び出す',
@@ -5182,6 +5197,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'Поставить в очередь',
settings_busy_input_mode_interrupt: 'Прервать текущий оборот',
settings_busy_input_mode_steer: 'Steer (прерывание + отправка)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
no_personalities: 'Личности не найдены (добавьте их в ~/.hermes/personalities/)',
clarify_heading: 'Требуется уточнение',
@@ -6803,6 +6823,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'Poner en cola',
settings_busy_input_mode_interrupt: 'Interrumpir turno actual',
settings_busy_input_mode_steer: 'Steer (corrección a mitad de turno)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
no_personalities: 'No se encontraron personalidades (añádelas a ~/.hermes/personalities/)',
available_personalities: 'Personalidades disponibles:',
personality_switch_hint: '\n\nUsa `/personality <name>` para cambiar, o `/personality none` para limpiar.',
@@ -8345,6 +8370,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'In Warteschlange einreihen',
settings_busy_input_mode_interrupt: 'Aktuellen Durchgang unterbrechen',
settings_busy_input_mode_steer: 'Steer (Korrektur ohne Unterbrechung)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
no_personalities: 'Keine Persönlichkeiten gefunden (füge sie in ~/.hermes/personalities/ hinzu)',
available_personalities: 'Verfügbare Persönlichkeiten:',
personality_switch_hint: '\n\nNutze `/personality <name>` zum Wechseln, oder `/personality none` zum Löschen.',
@@ -9946,6 +9976,11 @@ const LOCALES = {
settings_busy_input_mode_queue: '队列后续消息',
settings_busy_input_mode_interrupt: '中断当前轮次',
settings_busy_input_mode_steer: '引导(中间修正)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
settings_plugins_title: '插件',
plugins_enable_toggle: '启用',
settings_plugins_meta: '查看已安装的 Hermes 插件及其注册的生命周期挂钩。此面板为只读。',
@@ -11524,6 +11559,11 @@ const LOCALES = {
settings_busy_input_mode_queue: '加入佇列',
settings_busy_input_mode_interrupt: '中斷目前回合',
settings_busy_input_mode_steer: 'Steer(中途修正)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge: '技能',
slash_skill_desc: '叫用此技能',
@@ -13087,6 +13127,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'Enfileirar follow-up',
settings_busy_input_mode_interrupt: 'Interromper turno atual',
settings_busy_input_mode_steer: 'Steer (correção no meio do turno)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge: 'Skill',
slash_skill_desc: 'Invocar esta skill',
@@ -14604,6 +14649,11 @@ const LOCALES = {
settings_busy_input_mode_queue: '후속 메시지 대기',
settings_busy_input_mode_interrupt: '현재 작업 중단',
settings_busy_input_mode_steer: '조정(중간 수정)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge: '스킬',
slash_skill_desc: '이 스킬 실행',
@@ -16230,6 +16280,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'Suivi de file d\'attente',
settings_busy_input_mode_interrupt: 'Interrompre le tour en cours',
settings_busy_input_mode_steer: 'Steer (correction en milieu de tour)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge: 'Compétence',
slash_skill_desc: 'Invoquer cette compétence',
@@ -17854,6 +17909,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'Sıra takibi',
settings_busy_input_mode_interrupt: 'Mevcut dönüşü kes',
settings_busy_input_mode_steer: 'Yönlendirme (dönüş ortası düzeltme)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge: 'Yetenek',
slash_skill_desc: 'Bu beceriyi çağır',
@@ -19474,6 +19534,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'Kolejkuj tury',
settings_busy_input_mode_interrupt: 'Przerwij bieżącą turę',
settings_busy_input_mode_steer: 'Korekta w trakcie tury (steer)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge: 'Umiejętność',
slash_skill_desc: 'Wywołaj tę umiejętność',
@@ -21080,6 +21145,11 @@ const LOCALES = {
settings_busy_input_mode_queue: 'Xếp hàng phản hồi tiếp theo',
settings_busy_input_mode_interrupt: 'Ngắt lượt hiện tại',
settings_busy_input_mode_steer: 'Steer (điều chỉnh giữa lượt)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
slash_skill_badge:'Kỹ năng',
slash_skill_desc:'Gọi kỹ năng này',
+7
View File
@@ -1315,6 +1315,13 @@
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_busy_input_mode">Controls what happens when you send a message while the agent is running. Queue waits for the current task; Interrupt cancels and starts fresh; Steer injects a mid-turn correction without interrupting. If Steer is unavailable, the draft is restored so you can choose the next action.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowBusyPlaceholderHint" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_busy_placeholder_hint">Show busy placeholder hint</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_busy_placeholder_hint">Shows the composer placeholder with the current busy action while the session is running and the draft is empty.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
+54 -1
View File
@@ -6663,7 +6663,25 @@ function startSessionStream(sid) {
// Tab is visible — the real SSE owns live-view; ensure no stale hidden poll.
_stopHiddenActiveStreamPoll();
try {
const es = new EventSource(_apiUrl('api/session/stream?session_id=' + encodeURIComponent(sid)));
// Report our last-known message_count so the server's on-subscribe
// self-heal can detect a server-initiated turn (self-wake / cron / restart)
// that started AND finished entirely inside an SSE gap — the fire-and-forget
// `server_turn_started` was missed AND the run already cleared from
// ACTIVE_RUNS by the time we reconnect, so the `server_turn_started` replay
// finds nothing. When the server is ahead of this count it emits a
// `session-updated` frame (handled below) and we incrementally sync. Use the
// session's full message_count (same basis the server persists), NOT
// S.messages.length, which is only the rendered tail window for long
// sessions and would false-trigger a reload on every reconnect.
let _knownCount = '';
try {
if (S.session && S.session.session_id === sid && Number.isFinite(Number(S.session.message_count))) {
_knownCount = String(Number(S.session.message_count));
}
} catch (_) {}
const _streamUrl = 'api/session/stream?session_id=' + encodeURIComponent(sid)
+ (_knownCount !== '' ? '&known_count=' + encodeURIComponent(_knownCount) : '');
const es = new EventSource(_apiUrl(_streamUrl));
_sessionEventSource = es;
es.addEventListener('initial', () => { /* connection confirmed */ });
es.addEventListener('bg_task_complete', e => {
@@ -6672,6 +6690,41 @@ function startSessionStream(sid) {
_handleBgTaskCompleteEvent(e, sid, {source: 'session'});
}
});
// ── Visible-tab self-heal: a server-initiated turn finished during an SSE
// gap ─────────────────────────────────────────────────────────────────
// Distinct from `server_turn_started` (which attaches a LIVE stream): this
// frame fires when the server detected, at our (re)subscribe, that a turn
// started AND finished while our EventSource was momentarily down (so we
// missed the live broadcast and the run already cleared). The turn is
// persisted but our transcript is stale and there is NO live stream left to
// attach. Incrementally sync via the SAME swap-in-place path #5189 uses for
// the hidden-tab return (loadSession force + keepStaleUntilLoaded): the new
// transcript replaces the old in a single render frame — NO clear+refetch,
// so the #5177/#5189 blank-gap "jump" is not reintroduced.
es.addEventListener('session-updated', e => {
try {
const d = JSON.parse(e.data || '{}');
const evSid = d.session_id || sid;
if (evSid !== sid) return;
// Only act when this session is the one on screen and we're idle (no
// live turn rendering — that path owns its own message updates).
const isCurrent = (typeof _isSessionCurrentPane === 'function')
? _isSessionCurrentPane(sid)
: (S.session && S.session.session_id === sid);
if (!isCurrent) return;
if (S.activeStreamId) return;
// Re-check against our CURRENT known count — a concurrent load may have
// already caught us up between the server's emit and now.
const localCount = (S.session && S.session.session_id === sid && Number.isFinite(Number(S.session.message_count)))
? Number(S.session.message_count)
: (Array.isArray(S.messages) ? S.messages.length : 0);
const serverCount = Number(d.message_count);
if (!Number.isFinite(serverCount) || serverCount <= localCount) return;
if (typeof loadSession === 'function') {
void loadSession(sid, {force: true, externalRefreshReason: 'session-updated', keepStaleUntilLoaded: true});
}
} catch (_) {}
});
// ── Defect B: live-view of server-initiated (Option Z) turns ──────────
// The drain thread starts the wakeup turn server-side and the server
// fans a `server_turn_started` {stream_id} frame onto this per-session
+25 -3
View File
@@ -7434,6 +7434,8 @@ function _preferencesPayloadFromUi(){
if(autoTitleRefreshSel) payload.auto_title_refresh_every=parseInt(autoTitleRefreshSel.value,10);
const busyInputModeSel=$('settingsBusyInputMode');
if(busyInputModeSel) payload.busy_input_mode=busyInputModeSel.value;
const showBusyPlaceholderHintCb=$('settingsShowBusyPlaceholderHint');
if(showBusyPlaceholderHintCb) payload.show_busy_placeholder_hint=showBusyPlaceholderHintCb.checked;
const botNameField=$('settingsBotName');
if(botNameField) payload.bot_name=botNameField.value;
return payload;
@@ -7507,6 +7509,14 @@ async function _autosavePreferencesSettings(payload){
document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled';
if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference();
}
if(payload&&payload.busy_input_mode!==undefined){
window._busyInputMode=(saved&&saved.busy_input_mode)||'queue';
if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder();
}
if(payload&&payload.show_busy_placeholder_hint!==undefined){
window._showBusyPlaceholderHint=!!(saved&&saved.show_busy_placeholder_hint);
if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder();
}
_settingsPreferencesAutosaveRetryPayload=null;
_setPreferencesAutosaveStatus('saved');
// Only clear the global dirty flag and hide the unsaved-changes bar when
@@ -8072,8 +8082,16 @@ async function loadSettingsPanel(){
if(busyInputModeSel){
const val=String(settings.busy_input_mode||'queue');
busyInputModeSel.value=['queue','interrupt','steer'].includes(val)?val:'queue';
window._busyInputMode=busyInputModeSel.value;
busyInputModeSel.addEventListener('change',_schedulePreferencesAutosave,{once:false});
}
const showBusyPlaceholderHintCb=$('settingsShowBusyPlaceholderHint');
if(showBusyPlaceholderHintCb){
showBusyPlaceholderHintCb.checked=!!settings.show_busy_placeholder_hint;
window._showBusyPlaceholderHint=showBusyPlaceholderHintCb.checked;
showBusyPlaceholderHintCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});
}
if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder();
// Bot name — debounced autosave (text input)
const botNameField=$('settingsBotName');
if(botNameField){
@@ -9971,13 +9989,14 @@ async function deletePasskey(id){
}
function _applySavedSettingsUi(saved, body, opts){
const {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
const {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showBusyPlaceholderHint,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
window._showQuotaChip=showQuotaChip===true;
window._showConversationOutline=showConversationOutline===true;
document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled';
if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference();
window._showBusyPlaceholderHint=showBusyPlaceholderHint===true;
window._showTps=showTps;
window._fadeTextEffect=!!fadeTextEffect;
window._showCliSessions=showCliSessions;
@@ -10003,6 +10022,7 @@ function _applySavedSettingsUi(saved, body, opts){
}
window._botName=body.bot_name||'Hermes';
if(typeof applyBotName==='function') applyBotName();
else if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder();
if(typeof setLocale==='function') setLocale(language);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
const maxTokensField=$('settingsMaxTokens');
@@ -10554,6 +10574,7 @@ async function saveSettings(andClose){
const language=($('settingsLanguage')||{}).value||'en';
const sidebarDensity=($('settingsSidebarDensity')||{}).value==='detailed'?'detailed':'compact';
const busyInputMode=($('settingsBusyInputMode')||{}).value||'queue';
const showBusyPlaceholderHint=!!($('settingsShowBusyPlaceholderHint')||{}).checked;
const body={};
if(sendKey) body.send_key=sendKey;
@@ -10579,6 +10600,7 @@ async function saveSettings(andClose){
}
body.show_quota_chip=showQuotaChip===true;
body.show_conversation_outline=showConversationOutline===true;
body.show_busy_placeholder_hint=showBusyPlaceholderHint===true;
body.show_tps=showTps;
body.fade_text_effect=fadeTextEffect;
body.terminal_auto_expand_on_output=!!($('settingsTerminalAutoExpand')||{}).checked;
@@ -10626,7 +10648,7 @@ async function saveSettings(andClose){
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
}
}
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showBusyPlaceholderHint,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
const cpField=$('settingsCurrentPassword'); if(cpField) cpField.value='';
const pwField=$('settingsPassword'); if(pwField) pwField.value='';
@@ -10656,7 +10678,7 @@ async function saveSettings(andClose){
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
}
}
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showBusyPlaceholderHint,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
showToast(t('settings_saved'));
_settingsDirty=false;
_resetSettingsPanelState();
+16 -1
View File
@@ -1418,7 +1418,10 @@ async function loadSession(sid){
_setActiveSessionUrl(S.session.session_id);
if(typeof startSessionStream==='function') startSessionStream(S.session.session_id);
const activeStreamId=S.session.active_stream_id||null;
// `let` (not const): re-read below, after the awaited _ensureMessagesLoaded,
// so a server_turn_started that attaches a live stream MID-RELOAD is honored
// by the attach/idle decision instead of being clobbered by the stale snapshot.
let activeStreamId=S.session.active_stream_id||null;
// If the server says the session is idle, reset browser-side streaming flags
// NOW — before the async _ensureMessagesLoaded gap below. Without this,
// S.busy can remain true from a still-running stream in the PREVIOUS session
@@ -1654,6 +1657,18 @@ async function loadSession(sid){
// Attach pending user message if one is queued.
_mergePendingSessionMessage(S.session,S.messages);
// Self-heal-vs-live-render race guard (maintainer/Codex-reproduced; verified
// in an isolated instance). `activeStreamId` was snapshotted BEFORE the
// awaited _ensureMessagesLoaded above. During a force reload (the
// `session-updated` self-heal or any keepStaleUntilLoaded recovery), a
// server-initiated turn can fire `server_turn_started` mid-await and set
// S.activeStreamId for THIS sid. Without re-reading, the idle branch below
// would clear S.activeStreamId/S.busy off the stale (null) snapshot and
// silently kill the live turn's render. Fold a concurrently-attached
// same-session stream into activeStreamId so the existing attach branch
// (and all its `attachLiveStream(sid, activeStreamId, ...)` calls) keeps it.
activeStreamId = activeStreamId || ((S.activeStreamId && S.session && S.session.session_id===sid) ? S.activeStreamId : null);
if(activeStreamId){
S.busy=true;
S.activeStreamId=activeStreamId;
+61 -1
View File
@@ -453,6 +453,9 @@ const _recycleResetAttrs=[
'data-transparent-turn-toggle-bound',
'data-anchor-scene-live-owner',
'data-anchor-stream-id',
'data-latest-assistant-response',
'role',
'aria-label',
// Defensive reset for legacy/restored shells that may still carry the fallback live-turn marker.
'data-live-assistant-turn',
];
@@ -6063,6 +6066,33 @@ function getComposerPrimaryAction(){
return 'queue';
}
function _applyBusyComposerPlaceholder(){
const input=$('msg');
if(!input) return;
if(_compressionPlaceholderSaved!==null) return;
if(input.disabled) return;
if(_composerHasContent()) return;
const idlePlaceholder='Message '+assistantDisplayName()+'\u2026';
if(!window._showBusyPlaceholderHint||!S.busy){
input.placeholder=idlePlaceholder;
return;
}
const busyMode=window._busyInputMode||'queue';
const busyPlaceholderKey=busyMode==='interrupt'
? 'composer_placeholder_busy_interrupt'
: busyMode==='steer'
? 'composer_placeholder_busy_steer'
: 'composer_placeholder_busy_queue';
const busyPlaceholderFallback=busyMode==='interrupt'
? 'Enter = interrupt | /queue | /background | /steer'
: busyMode==='steer'
? 'Enter = steer | /queue | /background | /interrupt'
: 'Enter = queue | /interrupt | /background | /steer';
input.placeholder=typeof t==='function'
? (t(busyPlaceholderKey)||busyPlaceholderFallback)
: busyPlaceholderFallback;
}
function _setComposerPrimaryButtonIcon(btn,action){
// Queue/interrupt/steer icons are inline Lucide SVGs (ISC):
// https://lucide.dev/icons/
@@ -6080,7 +6110,10 @@ function _setComposerPrimaryButtonIcon(btn,action){
function updateSendBtn(){
const btn=$('btnSend');
if(!btn) return;
if(!btn){
if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder();
return;
}
const action=getComposerPrimaryAction();
btn.dataset.action=action;
btn.classList.toggle('stop',action==='stop');
@@ -6102,6 +6135,7 @@ function updateSendBtn(){
btn.title=_btnTitle;
btn.setAttribute('aria-label',_btnTitle);
_setComposerPrimaryButtonIcon(btn,action);
if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder();
// Single primary action button: while busy/no-draft it becomes the red Stop
// action; while busy with a draft it reflects queue/interrupt/steer.
btn.style.display='';
@@ -8634,6 +8668,24 @@ function _createAssistantTurn(tsTitle='', tpsText=''){
row.innerHTML=`${_assistantRoleHtml(tsTitle, tpsText)}<div class="assistant-turn-blocks"></div>`;
return row;
}
function _setLatestAssistantTurnLandmark(turn, isLatest){
if(!turn) return;
const label='Latest Hermes response';
if(isLatest){
if(typeof document!=='undefined'){
document.querySelectorAll('.assistant-turn[data-latest-assistant-response="true"]').forEach(el=>{
if(el!==turn) _setLatestAssistantTurnLandmark(el, false);
});
}
turn.setAttribute('role','region');
turn.setAttribute('aria-label',label);
turn.dataset.latestAssistantResponse='true';
return;
}
if(turn.getAttribute('role')==='region') turn.removeAttribute('role');
if(turn.getAttribute('aria-label')===label) turn.removeAttribute('aria-label');
delete turn.dataset.latestAssistantResponse;
}
function _assistantTurnBlocks(turn){
return turn?turn.querySelector('.assistant-turn-blocks'):null;
}
@@ -12342,6 +12394,13 @@ function renderMessages(options){
}catch(e){}
}
const transparentToolResultsByTid=_transparentModeActive?_collectToolResultSnippetsByTid(S.messages):{};
const latestRenderedAssistantRawIdx=(()=>{
for(let i=renderVisWithIdx.length-1;i>=0;i--){
const entry=renderVisWithIdx[i];
if(entry&&entry.m&&entry.m.role==='assistant'&&!entry.m._live) return entry.rawIdx;
}
return -1;
})();
// Windowed render loop replaces the legacy full loop:
// for(let vi=0;vi<visWithIdx.length;vi++)
for(let vi=0;vi<renderVisWithIdx.length;vi++){
@@ -12529,6 +12588,7 @@ function renderMessages(options){
currentAssistantTurn.dataset.recycleKey=rawIdx;
inner.appendChild(currentAssistantTurn);
}
_setLatestAssistantTurnLandmark(currentAssistantTurn, !m._live&&rawIdx===latestRenderedAssistantRawIdx);
const seg=document.createElement('div');
if(Array.isArray(orderedTransparentParts)&&orderedTransparentParts.length){
const blocks=_assistantTurnBlocks(currentAssistantTurn);
+3 -2
View File
@@ -3,9 +3,9 @@
Mirrors the structure of test_1003_appearance_autosave.py to verify the
preferences-panel autosave pattern is wired correctly:
- All 14 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
- All 15 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
- Password field MUST still call _markSettingsDirty (security: never autosave)
- _preferencesPayloadFromUi covers all 14 fields
- _preferencesPayloadFromUi covers all 15 fields
- _setPreferencesAutosaveStatus uses the shared i18n keys
- Status div exists in static/index.html
- _autosavePreferencesSettings clears the dirty flag and hides the unsaved bar
@@ -50,6 +50,7 @@ PREFERENCE_FIELDS_AUTOSAVE = [
("settingsSidebarDensity", "sidebar_density"),
("settingsAutoTitleRefresh", "auto_title_refresh_every"),
("settingsBusyInputMode", "busy_input_mode"),
("settingsShowBusyPlaceholderHint", "show_busy_placeholder_hint"),
("settingsBotName", "bot_name"),
]
@@ -0,0 +1,89 @@
"""Regression checks for Issue #5144 busy composer placeholder hints."""
import re
from pathlib import Path
ROOT = Path(__file__).parent.parent
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
def _function_block(src: str, name: str) -> str:
marker = re.search(rf"(^|\n)(?:async\s+)?function\s+{re.escape(name)}\(", src)
assert marker is not None, f"{name}() not found"
start = marker.start()
next_marker = re.search(r"\n(?:function\s+\w+\(|async\s+function\s+\w+\()", src[start + 1:])
end = start + 1 + next_marker.start() if next_marker else len(src)
return src[start:end]
def test_setting_defaults_off_and_bool_registration():
assert '"show_busy_placeholder_hint": False' in CONFIG_PY
assert '"show_busy_placeholder_hint"' in CONFIG_PY
def test_preferences_wiring_covers_busy_placeholder_hint():
payload_block = _function_block(PANELS_JS, "_preferencesPayloadFromUi")
assert "$('settingsShowBusyPlaceholderHint')" in payload_block
assert "payload.show_busy_placeholder_hint=" in payload_block
load_block = _function_block(PANELS_JS, "loadSettingsPanel")
assert "settingsShowBusyPlaceholderHint" in load_block
assert "show_busy_placeholder_hint" in load_block
assert "_schedulePreferencesAutosave" in load_block
assert "_applyBusyComposerPlaceholder" in load_block
save_block = _function_block(PANELS_JS, "saveSettings")
assert "const showBusyPlaceholderHint=" in save_block
assert "body.show_busy_placeholder_hint=showBusyPlaceholderHint===true" in save_block
autosave_block = _function_block(PANELS_JS, "_autosavePreferencesSettings")
assert "window._showBusyPlaceholderHint" in autosave_block
assert "_applyBusyComposerPlaceholder" in autosave_block
apply_block = _function_block(PANELS_JS, "_applySavedSettingsUi")
assert "showBusyPlaceholderHint" in apply_block
assert "window._showBusyPlaceholderHint=showBusyPlaceholderHint===true" in apply_block
assert "_applyBusyComposerPlaceholder" in apply_block
def test_boot_loads_runtime_flag_and_falls_back_false():
assert "window._showBusyPlaceholderHint=!!s.show_busy_placeholder_hint" in BOOT_JS
assert "window._showBusyPlaceholderHint=false" in BOOT_JS
def test_busy_placeholder_helper_preserves_compression_and_drafts():
helper_block = _function_block(UI_JS, "_applyBusyComposerPlaceholder")
assert "if(!input)" in helper_block
assert "if(_compressionPlaceholderSaved!==null)" in helper_block
assert "if(input.disabled)" in helper_block
assert "if(_composerHasContent())" in helper_block
assert "assistantDisplayName()" in helper_block
assert "window._showBusyPlaceholderHint" in helper_block
assert "window._busyInputMode||'queue'" in helper_block
assert "composer_placeholder_busy_queue" in helper_block
assert "composer_placeholder_busy_interrupt" in helper_block
assert "composer_placeholder_busy_steer" in helper_block
update_block = _function_block(UI_JS, "updateSendBtn")
assert "_applyBusyComposerPlaceholder" in update_block
busy_block = _function_block(UI_JS, "setBusy")
assert "updateSendBtn();" in busy_block
assert "_applyBusyComposerPlaceholder" not in busy_block
def test_locale_blocks_cover_new_keys():
locale_blocks = I18N_JS.count("settings_busy_input_mode_steer")
assert locale_blocks == 14
for key in [
"settings_label_busy_placeholder_hint",
"settings_desc_busy_placeholder_hint",
"composer_placeholder_busy_queue",
"composer_placeholder_busy_interrupt",
"composer_placeholder_busy_steer",
]:
assert I18N_JS.count(key) == locale_blocks, f"{key} should exist in every locale block"
+54
View File
@@ -0,0 +1,54 @@
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
def _function_body(source: str, name: str) -> str:
start = source.find(f"function {name}(")
assert start != -1, f"{name}() not found"
end = source.find("\nfunction ", start + 1)
if end == -1:
end = len(source)
return source[start:end]
def test_conversation_transcript_is_not_a_named_region():
match = re.search(r'<div class="messages" id="messages"[^>]*>', INDEX_HTML)
assert match, "#messages container not found"
tag = match.group(0)
assert 'role="region"' not in tag
assert 'Conversation transcript' not in tag
assert "tabindex" not in tag.lower()
def test_latest_assistant_landmark_helper_is_named_but_not_focusable():
helper = _function_body(UI_JS, "_setLatestAssistantTurnLandmark")
assert "Latest Hermes response" in helper
assert "setAttribute('role','region')" in helper
assert "setAttribute('aria-label',label)" in helper
assert "data-latest-assistant-response" in helper
assert "tabindex" not in helper.lower()
assert "<h" not in helper.lower()
def test_settled_render_marks_only_the_latest_assistant_turn():
render_messages = _function_body(UI_JS, "renderMessages")
assert "const latestRenderedAssistantRawIdx=(()=>{" in render_messages
assert "if(entry&&entry.m&&entry.m.role==='assistant'&&!entry.m._live) return entry.rawIdx;" in render_messages
assert "_setLatestAssistantTurnLandmark(currentAssistantTurn, !m._live&&rawIdx===latestRenderedAssistantRawIdx);" in render_messages
assert "seg.setAttribute('role','region')" not in render_messages
assert "orderedSeg.setAttribute('role','region')" not in render_messages
def test_live_assistant_turn_paths_do_not_own_final_response_landmark():
restore_live_turn = _function_body(UI_JS, "restoreLiveTurnHtmlForSession")
render_live_scene = _function_body(UI_JS, "renderLiveAnchorActivityScene")
append_thinking = _function_body(UI_JS, "appendThinking")
assert "_setLatestAssistantTurnLandmark(restored, true);" not in restore_live_turn
assert "_setLatestAssistantTurnLandmark(turn, true);" not in render_live_scene
assert "_setLatestAssistantTurnLandmark(turn, true);" not in append_thinking
+1
View File
@@ -604,6 +604,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
return turn;
}}
function _assistantTurnBlocks(turn) {{ return turn ? turn.querySelector('.assistant-turn-blocks') : null; }}
function _setLatestAssistantTurnLandmark() {{}}
function _assistantRoleHtml() {{ return ''; }}
function _userMessageDomId(rawIdx) {{ return `user-${{rawIdx}}`; }}
function _messageSessionIndexForRawIdx(rawIdx) {{ return rawIdx; }}
+1 -1
View File
@@ -129,7 +129,7 @@ if(!_isDuplicateGatewaySessionSnapshot([null, {session_id:'web-2', session_sourc
def test_load_session_persists_only_after_metadata_loads():
"""Do not overwrite the last good localStorage sid before /api/session succeeds."""
src = _read(SESSIONS_JS)
load = _block(src, "async function loadSession(sid)", "\n const activeStreamId=")
load = _block(src, "async function loadSession(sid)", "activeStreamId=S.session.active_stream_id")
api_pos = load.index("data = await api(`/api/session")
persist_pos = load.index("localStorage.setItem('hermes-webui-session',S.session.session_id)")
+5 -1
View File
@@ -138,7 +138,11 @@ def test_attach_live_stream_different_stream_still_reopens_transport():
def test_load_session_reattach_path_uses_attach_live_stream_for_running_sessions():
"""The session switch-back path should still route through attachLiveStream()."""
body = _function_body(SESSIONS_JS, "loadSession")
active_pos = body.find("const activeStreamId=S.session.active_stream_id||null")
# Anchor without the `const`/`let` keyword: the declaration was widened to
# `let` so the race-guard can re-read activeStreamId after the awaited
# message load (#5248). The invariant this test pins — the snapshot is taken
# before, and used by, the attachLiveStream reattach — is unchanged.
active_pos = body.find("activeStreamId=S.session.active_stream_id||null")
reattach_pos = body.find("attachLiveStream(sid, activeStreamId")
assert active_pos != -1
assert reattach_pos != -1
@@ -89,6 +89,8 @@ const S = { session: { session_id: 'sess-1' } };
const _assistantRoleHtml = (title, tps) => `role:${title}:${tps}`;
const isTpsDisplayEnabled = () => true;
const _formatTurnTps = value => `tps:${value}`;
const latestRenderedAssistantRawIdx = rawIdx;
function _setLatestAssistantTurnLandmark() {}
eval(recycleChunk);
console.log(JSON.stringify({
removedAttrs,
@@ -110,6 +112,9 @@ console.log(JSON.stringify({
"data-transparent-turn-toggle-bound",
"data-anchor-scene-live-owner",
"data-anchor-stream-id",
"data-latest-assistant-response",
"role",
"aria-label",
"data-live-assistant-turn",
}
assert set(result["resetAttrs"]) == expected_attrs
@@ -155,6 +160,8 @@ const S = { session: { session_id: 'sess-1' } };
const _assistantRoleHtml = (title, tps) => `role:${title}:${tps}`;
const isTpsDisplayEnabled = () => true;
const _formatTurnTps = value => `tps:${value}`;
const latestRenderedAssistantRawIdx = rawIdx;
function _setLatestAssistantTurnLandmark() {}
eval(recycleChunk);
console.log(JSON.stringify({
blocksInnerHTML: blocks.innerHTML,
@@ -42,7 +42,7 @@ def test_model_selection_records_pending_state_before_async_session_update():
def test_load_session_applies_pending_model_before_first_topbar_sync():
"""Reload should project the pending selection before server old metadata wins."""
body = _body_between(SESSIONS_JS, "async function loadSession", "const activeStreamId=")
body = _body_between(SESSIONS_JS, "async function loadSession", "activeStreamId=S.session.active_stream_id")
apply_idx = body.index("_applyPendingSessionModelForSession")
sync_idx = body.index("syncTopbar()")
+211 -1
View File
@@ -368,7 +368,7 @@ def test_session_sse_handler_wires_on_subscribe_recovery():
# The recovery must be inside the session SSE handler and use the
# recovered marker so the frontend uses the replay attach path.
handler_ix = src.index("def _handle_session_sse_stream")
handler_src = src[handler_ix:handler_ix + 6000]
handler_src = src[handler_ix:handler_ix + 9000]
assert "active_stream_id_for_session" in handler_src
assert '"recovered": True' in handler_src
assert "server_turn_started" in handler_src
@@ -551,3 +551,213 @@ def test_frontend_handler_requires_event_id_to_surface():
assert "_bgTaskCompleteRingBufferAdd(sid, evt_id)" in fn_src
# Ack body carries event_id so server can correlate.
assert "event_id: evt_id" in fn_src
# ---------------------------------------------------------------------------
# Visible-tab self-heal: server-initiated turn FINISHED during the SSE gap
# ---------------------------------------------------------------------------
#
# Root cause (distinct from the live-run replay above): a server-initiated
# turn (self-wake / cron / restart hook) can start AND finish entirely while a
# VISIBLE tab's per-session EventSource is momentarily down (transient SSE
# drop / reverse-proxy idle-timeout / connection-pool starvation). The
# fire-and-forget `server_turn_started` reaches no subscriber, and by the time
# the tab reconnects the run has already cleared from ACTIVE_RUNS — so
# active_stream_id_for_session returns None and the live-run replay finds
# nothing. The turn IS persisted; only the tab's live-view is stale until a
# hard refresh.
#
# Fix: the (re)subscribing tab reports its last-known message_count
# (?known_count); when the live-run replay is a no-op, the handler compares the
# persisted count against it and, if the server is AHEAD, emits a lightweight
# `session-updated` frame so the tab incrementally syncs via the #5189
# keepStaleUntilLoaded swap-in-place path (NO clear+refetch → no blank-gap
# jump). Reproduced deterministically with the controlled Playwright harness in
# references/visible-tab-misses-wake-during-sse-gap.md (SSE force-closed at the
# wakeup-emit instant; pre-fix gotServerTurnStarted:false / msgCount:0 while the
# sidecar already had the turn).
# ---------------------------------------------------------------------------
def _make_meta_session(sid, count, updated_at=123.0):
"""Build a fake metadata-only session stub mirroring load_metadata_only()."""
class _MetaSession:
session_id = sid
_metadata_message_count = count
_loaded_metadata_only = True
messages = []
updated_at = None
s = _MetaSession()
s.updated_at = updated_at
return s
def test_persisted_message_count_uses_metadata_only(monkeypatch):
"""The companion lookup must return the persisted count via a metadata-only
load (never parsing the full transcript) and None when unknown."""
from api import background_process as bp
import api.models as models
sid = "sess-persisted-count"
# Normal: metadata stub carries _metadata_message_count.
monkeypatch.setattr(models, "get_session", lambda _sid, metadata_only=False: _make_meta_session(_sid, 7), raising=True)
assert bp.persisted_message_count_for_session(sid) == 7
# Unknown count (legacy sidecar, no persisted count, empty messages) → None
# so the caller treats it as "cannot tell", never a spurious trigger.
monkeypatch.setattr(models, "get_session", lambda _sid, metadata_only=False: _make_meta_session(_sid, None), raising=True)
assert bp.persisted_message_count_for_session(sid) is None
# Lookup failure (e.g. corrupt sidecar) is swallowed → None, never raises.
def _boom(_sid, metadata_only=False):
raise RuntimeError("decode error")
monkeypatch.setattr(models, "get_session", _boom, raising=True)
assert bp.persisted_message_count_for_session(sid) is None
def test_persisted_message_count_requests_metadata_only(monkeypatch):
"""Guard the perf contract: the lookup MUST pass metadata_only=True so it
never parses a 400KB+ transcript on every per-session SSE (re)connect."""
from api import background_process as bp
import api.models as models
seen = {}
def _spy(_sid, metadata_only=False):
seen["metadata_only"] = metadata_only
return _make_meta_session(_sid, 3)
monkeypatch.setattr(models, "get_session", _spy, raising=True)
assert bp.persisted_message_count_for_session("sess-spy") == 3
assert seen.get("metadata_only") is True
def test_session_sse_handler_wires_finished_during_gap_self_heal():
"""Source-grep: the per-session SSE handler must (a) parse ?known_count,
(b) in the live-run-absent branch compare the persisted count against it,
and (c) emit a `session-updated` frame when the server is ahead — all so a
turn that finished during the SSE gap still self-heals on a visible tab."""
src = (REPO_ROOT / "api" / "routes.py").read_text()
handler_ix = src.index("def _handle_session_sse_stream")
handler_src = src[handler_ix:handler_ix + 9000]
# (a) the subscriber reports its last-known count.
assert "known_count" in handler_src
assert "subscriber_known_count" in handler_src
# (b) the no-live-run branch consults the persisted count companion.
assert "persisted_message_count_for_session" in handler_src
# (c) and emits the incremental-sync frame only when ahead.
assert "session-updated" in handler_src
# The persisted-count comparison must live in the ELSE of the live-run
# replay (a finished-during-gap turn has NO active stream to replay), and
# the count comparison gates the emit. Anchor on the EMIT call itself
# (`_sse(handler, 'session-updated'`), not the first `session-updated`
# occurrence — that one is in the explanatory comment, before the gate.
su_ix = handler_src.index("_sse(handler, 'session-updated'")
gate_src = handler_src[max(0, su_ix - 1200):su_ix]
assert "should_emit_session_updated(" in gate_src
def test_session_updated_emit_gate_is_the_real_handler_function():
"""Behavioural contract for the count gate — exercises the ACTUAL function
the SSE handler calls (`should_emit_session_updated`), NOT a hand-rolled
copy. If the gate drifts (e.g. `>` becomes `>=`, or a short-circuit is
added), this test moves with it because it imports the real implementation.
Emit ONLY when a known subscriber count exists AND the persisted count
strictly exceeds it.
"""
from api.background_process import should_emit_session_updated as gate
# Server ahead → emit.
assert gate(5, 7) is True
# Equal (tab already current) → no emit.
assert gate(7, 7) is False
# Behind (shouldn't happen, but never reload backwards) → no emit.
assert gate(7, 5) is False
# Tab didn't report a count → no emit.
assert gate(None, 7) is False
# Persisted count unknown (legacy sidecar) → no emit.
assert gate(5, None) is False
# Both unknown → no emit.
assert gate(None, None) is False
def test_sse_handler_uses_shared_emit_gate_not_inline_comparison():
"""The handler MUST call `should_emit_session_updated` rather than inline
the `persisted_count > subscriber_known_count` comparison — that shared gate
is the single source of truth the behavioural test above locks down. An
inline comparison would let the handler's real logic drift away from the
tested function (greptile P2 r…: the gate test must exercise the handler,
not a copy)."""
src = (REPO_ROOT / "api" / "routes.py").read_text()
handler_ix = src.index("def _handle_session_sse_stream")
handler_src = src[handler_ix:handler_ix + 9000]
# The shared gate is imported and called in the emit branch.
assert "should_emit_session_updated" in handler_src
# And the inline form is GONE (the comparison lives only in the gate fn).
su_ix = handler_src.index("_sse(handler, 'session-updated'")
gate_src = handler_src[max(0, su_ix - 1200):su_ix]
assert "should_emit_session_updated(" in gate_src
assert "> subscriber_known_count" not in gate_src
def test_frontend_subscribes_with_known_count_and_handles_session_updated():
"""Source-grep: the frontend must (a) append &known_count from the
session's FULL message_count (not the rendered tail S.messages.length), and
(b) handle the `session-updated` frame via the keepStaleUntilLoaded
swap-in-place loadSession path (no clear+refetch → no blank-gap jump)."""
js = (REPO_ROOT / "static" / "messages.js").read_text()
# (a) known_count is sent on subscribe, sourced from S.session.message_count.
assert "known_count" in js
ss_ix = js.index("function startSessionStream")
ss_src = js[ss_ix:ss_ix + 4000]
assert "known_count=" in ss_src
assert "S.session.message_count" in ss_src
# Must NOT key the reported count on S.messages.length (tail window) — that
# would false-trigger a reload on every reconnect for long sessions.
knc_ix = ss_src.index("_knownCount")
knc_src = ss_src[knc_ix:knc_ix + 400]
assert "S.messages.length" not in knc_src
# (b) the session-updated listener routes through the swap-in-place path.
su_ix = js.index("addEventListener('session-updated'")
su_src = js[su_ix:su_ix + 1400]
assert "keepStaleUntilLoaded: true" in su_src or "keepStaleUntilLoaded:true" in su_src
assert "loadSession(" in su_src
# Idle-only: must bail when a live turn is rendering (that path owns its own
# message updates) and when the session isn't the one on screen.
assert "S.activeStreamId" in su_src
assert "isCurrent" in su_src
def test_loadsession_idle_cleanup_does_not_clobber_concurrent_live_stream():
"""Self-heal-vs-live-render race guard (maintainer/Codex-reproduced; verified
in an isolated instance).
The `session-updated` self-heal calls `loadSession(force, keepStaleUntilLoaded)`,
which awaits `_ensureMessagesLoaded`. A server-initiated turn can fire
`server_turn_started` DURING that await and set `S.activeStreamId` for the
same sid. The post-await branch must NOT clear `S.activeStreamId`/`S.busy`
based only on the stale `activeStreamId` snapshot taken before the await —
that silently kills the live turn's render. It must re-read the LIVE state and
prefer a concurrently-attached same-session stream over the stale snapshot.
"""
js = (REPO_ROOT / "static" / "sessions.js").read_text()
compact = js.replace(" ", "").replace("\n", "")
# `activeStreamId` is declared `let` (not const) so it can be re-read.
assert "letactiveStreamId=S.session.active_stream_id||null" in compact, (
"activeStreamId must be `let` so the race-guard can re-read it post-await"
)
# Before the attach/idle branch, fold a concurrently-attached same-session
# live stream into activeStreamId so a mid-reload server_turn_started is
# preserved (attached), not clobbered by the idle cleanup.
assert "activeStreamId=activeStreamId||((S.activeStreamId&&S.session&&S.session.session_id===sid)?S.activeStreamId:null)" in compact, (
"missing the post-await race-guard re-read that folds a concurrent "
"same-session live stream into activeStreamId before the idle branch"
)
# The re-read must sit BEFORE the attach/idle decision (`if(activeStreamId){`).
reread_ix = compact.find("activeStreamId=activeStreamId||((S.activeStreamId")
branch_ix = compact.find("if(activeStreamId){")
assert reread_ix != -1 and branch_ix != -1 and reread_ix < branch_ix, (
"race-guard re-read must precede the attach/idle branch"
)
+5 -2
View File
@@ -642,8 +642,11 @@ def test_reload_path_restores_pending_message_and_reattaches_live_stream(cleanup
assert 'pending_user_message' in ui_src
assert 'function attachLiveStream' in messages_src
assert 'const pendingMsg=typeof getPendingSessionMessage' in sessions_src
assert ('const activeStreamId=data.session.active_stream_id||null;' in sessions_src or
'const activeStreamId=S.session.active_stream_id||null;' in sessions_src)
# `activeStreamId` declaration was widened const → let (#5248 race-guard
# re-reads it after the awaited message load). Accept either keyword via a
# prefix anchor that omits const/let.
assert ('activeStreamId=data.session.active_stream_id||null;' in sessions_src or
'activeStreamId=S.session.active_stream_id||null;' in sessions_src)
assert 'attachLiveStream(sid, activeStreamId' in sessions_src
assert 'if (S.activeStreamId && S.activeStreamId === streamId) return;' in ui_src
active_branch_start = sessions_src.index("if(activeStreamId){\n S.busy=true;")