Merge origin/master into PR #5271
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
2ffb8ab470
21 files changed
+727
-14
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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"
|
||||
@@ -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
|
||||
@@ -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; }}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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()")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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;")
|
||||
|
||||
Reference in new issue
Block a user