Release v0.51.347 — Release LK (streaming & render reliability cluster #3892 #3898 #3885 #3882 #3868) (#3902)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
* stage v0.51.347: render/stream cluster (#3892 #3898 #3885 #3882 #3868) + 2 Opus SHOULD-FIX * stage v0.51.347: trim #3885 error-guard comment to fit diagnostic-test window * Stamp v0.51.347 — Release LK (streaming & render reliability cluster) * Remove stray uv.lock accidentally staged (not part of any cluster PR) --------- Co-authored-by: nesquena-hermes <[email protected]>
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -3,6 +3,19 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.347] — 2026-06-09 — Release LK (streaming & render reliability cluster)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mid-stream content no longer flickers (disappears and reappears) during a live turn.** When `renderMessages()` rebuilt the transcript while a session was actively streaming (e.g. a clarify-response echo or a CLI-import refresh), the `inner.innerHTML=''` wipe detached the live assistant turn node that the streaming markdown parser keeps writing into, so the streamed text vanished until the next stream event. The live turn's DOM node is now preserved across the rebuild and re-attached so the parser target stays connected and the text never blanks. (#3877)
|
||||
- **Recovered turns with empty visible content no longer render a blank transcript.** A run-journal-recovered assistant anchor (empty content + a `reasoning` payload + recovery marker) extracted no inline thinking text and rendered nothing, so a session made of such rows painted as only date separators. Two fixes: the backend now reuses one empty recovered anchor per stream instead of appending a fresh empty row on every lazy recovery retry (which previously bloated sessions with thousands of content-less rows), and the renderer surfaces the message's reasoning payload as a Thinking card for empty-content turns so the turn is never blank. (#3875)
|
||||
- **`stream_end` no longer finalizes a turn prematurely when the server is still active.** When a `stream_end` event arrived while the session was still streaming server-side, the client could settle to a partial state. It now polls the persisted session and retries settlement (bounded) until the server reports the stream complete, and the SSE error path defers to an in-flight recovery instead of starting a competing reconnect. (#3885)
|
||||
- **Markdown list markers and indentation are preserved around LaTeX blocks.** Continuation lines, nested indentation, and KaTeX-placeholder lines inside list items no longer break the list into fragments or lose their markers. (#3830)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Worklog details settings now align with the live-to-final model.** The old "Activity expanded by default" setting is renamed to **Worklog details** (default folded), the legacy "Compact tool activity" preference is deprecated, and the Worklog renderer stays enabled for older installs that had saved `simplified_tool_calling=false`. (#3400, #3820)
|
||||
|
||||
## [v0.51.346] — 2026-06-09 — Release LJ (PWA notification controls)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -5488,7 +5488,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"font_size": "default", # small | default | large | xlarge
|
||||
"session_jump_buttons": False, # show Start/End transcript jump pills
|
||||
"session_endless_scroll": False, # auto-load older transcript pages while scrolling upward
|
||||
"activity_feed_expanded_default": False, # expand Activity disclosures by default for new turns
|
||||
"worklog_details_expanded_default": False, # opt-in: expand Worklog details by default; default remains folded
|
||||
"pinned_sessions_limit": 3, # maximum active pinned sessions shown in the sidebar
|
||||
"inflight_state_max_sessions": 8, # max active-stream recovery snapshots kept in browser localStorage
|
||||
"inflight_state_max_messages": 24, # max recent messages kept per recovery snapshot
|
||||
@@ -5505,7 +5505,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"rtl": False, # right-to-left chat layout (chat messages + composer only)
|
||||
"notifications_enabled": False, # browser notification when tab is in background
|
||||
"show_thinking": True, # show/hide thinking/reasoning blocks in chat view
|
||||
"simplified_tool_calling": True, # render tools/thinking as compact inline timeline activity
|
||||
"simplified_tool_calling": True, # legacy compatibility; Worklog renderer remains enabled
|
||||
"terminal_auto_expand_on_output": False, # auto-expand terminal panel when output arrives while collapsed
|
||||
"api_redact_enabled": True, # redact sensitive data (API keys, secrets) from API responses
|
||||
"dashboard_plugins": {}, # plugin_name -> bool, opt-in per plugin (default off per PF-10b)
|
||||
@@ -5514,7 +5514,13 @@ _SETTINGS_DEFAULTS = {
|
||||
"busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer
|
||||
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||
}
|
||||
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language", "bubble_layout", "default_model"}
|
||||
_SETTINGS_LEGACY_DROP_KEYS = {
|
||||
"assistant_language",
|
||||
"bubble_layout",
|
||||
"default_model",
|
||||
"activity_feed_expanded_default",
|
||||
"simplified_tool_calling",
|
||||
}
|
||||
_SETTINGS_THEME_VALUES = {"light", "dark", "system"}
|
||||
_SETTINGS_SKIN_VALUES = {
|
||||
"default",
|
||||
@@ -5595,6 +5601,13 @@ def load_settings() -> dict:
|
||||
try:
|
||||
stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(stored, dict):
|
||||
if (
|
||||
"worklog_details_expanded_default" not in stored
|
||||
and "activity_feed_expanded_default" in stored
|
||||
):
|
||||
settings["worklog_details_expanded_default"] = bool(
|
||||
stored.get("activity_feed_expanded_default")
|
||||
)
|
||||
settings.update(
|
||||
{
|
||||
k: v
|
||||
@@ -5621,6 +5634,7 @@ def load_settings() -> dict:
|
||||
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
|
||||
"password_hash",
|
||||
"default_model",
|
||||
"simplified_tool_calling",
|
||||
}
|
||||
_SETTINGS_ENUM_VALUES = {
|
||||
"send_key": {"enter", "ctrl+enter"},
|
||||
@@ -5655,12 +5669,11 @@ _SETTINGS_BOOL_KEYS = {
|
||||
"rtl",
|
||||
"notifications_enabled",
|
||||
"show_thinking",
|
||||
"simplified_tool_calling",
|
||||
"terminal_auto_expand_on_output",
|
||||
"api_redact_enabled",
|
||||
"session_jump_buttons",
|
||||
"session_endless_scroll",
|
||||
"activity_feed_expanded_default",
|
||||
"worklog_details_expanded_default",
|
||||
}
|
||||
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
|
||||
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
|
||||
@@ -5669,6 +5682,15 @@ _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8}
|
||||
def save_settings(settings: dict) -> dict:
|
||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||
current = load_settings()
|
||||
if (
|
||||
"worklog_details_expanded_default" not in settings
|
||||
and "activity_feed_expanded_default" in settings
|
||||
):
|
||||
settings["worklog_details_expanded_default"] = settings.get(
|
||||
"activity_feed_expanded_default"
|
||||
)
|
||||
settings.pop("activity_feed_expanded_default", None)
|
||||
settings.pop("simplified_tool_calling", None)
|
||||
pending_theme = current.get("theme")
|
||||
pending_skin = current.get("skin")
|
||||
theme_was_explicit = False
|
||||
|
||||
@@ -1421,6 +1421,30 @@ def _append_journaled_partial_output(
|
||||
# A stream can start with tools before any text. Keep those tools
|
||||
# visible after restart with an empty recovered assistant anchor instead
|
||||
# of inventing synthetic progress prose.
|
||||
#
|
||||
# Dedup guard (#3875): reuse an existing empty recovered anchor for THIS
|
||||
# stream instead of appending a fresh one. The lazy read-side retry path
|
||||
# (_retry_journal_recovery_in_place) re-runs this recovery on repeated
|
||||
# get_session() calls, and a tool-first stream that never emitted text
|
||||
# has no content to dedup on (flush_assistant() returns early on empty),
|
||||
# so without this guard each retry — and each distinct interrupted stream
|
||||
# over the session's life — appends another empty anchor. A session that
|
||||
# was interrupted-and-recovered many times then accumulates thousands of
|
||||
# empty content-less assistant rows, bloating the file and (combined with
|
||||
# the render path) painting the transcript blank. One anchor per stream
|
||||
# is all that's needed to host its recovered tool cards.
|
||||
for _existing_idx in range(len(session.messages) - 1, -1, -1):
|
||||
_m = session.messages[_existing_idx]
|
||||
if not isinstance(_m, dict):
|
||||
continue
|
||||
if (
|
||||
_m.get('_recovered_from_run_journal')
|
||||
and _m.get('_recovered_stream_id') == stream_id
|
||||
and _m.get('role') == 'assistant'
|
||||
and not str(_m.get('content') or '').strip()
|
||||
):
|
||||
current_assistant_idx = _existing_idx
|
||||
return _existing_idx
|
||||
session.messages.append({
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
|
||||
@@ -1794,9 +1794,13 @@ function applyBotName(){
|
||||
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
|
||||
window._whatsNewSummaryEnabled=!!s.whats_new_summary_enabled;
|
||||
window._showThinking=s.show_thinking!==false;
|
||||
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
|
||||
window._simplifiedToolCalling=true;
|
||||
window._terminalAutoExpandOnOutput=!!s.terminal_auto_expand_on_output;
|
||||
window._activityFeedExpandedDefault=!!s.activity_feed_expanded_default;
|
||||
window._worklogDetailsExpandedByDefault=!!(
|
||||
Object.prototype.hasOwnProperty.call(s,'worklog_details_expanded_default')
|
||||
? s.worklog_details_expanded_default
|
||||
: s.activity_feed_expanded_default
|
||||
);
|
||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||
window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3;
|
||||
window._inflightStateLimits={
|
||||
|
||||
@@ -558,8 +558,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: 'Load older messages while scrolling up',
|
||||
|
||||
settings_desc_session_endless_scroll: 'When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: 'Sidebar tabs',
|
||||
settings_desc_tab_visibility: 'Choose which tabs appear in the sidebar and rail. Drag chips to reorder them. Chat and Settings are always visible.',
|
||||
@@ -1962,8 +1962,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: 'Carica messaggi precedenti scorrendo in alto',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Se abilitato, i messaggi precedenti si caricano automaticamente scorrendo in alto. Se disabilitato, usa il pulsante messaggi precedenti.',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: 'Schede della barra laterale',
|
||||
settings_desc_tab_visibility: 'Scegli quali schede mostrare nella barra laterale e nel rail. Chat e Impostazioni sono sempre visibili.',
|
||||
@@ -3358,8 +3358,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: '上スクロールで古いメッセージを読み込む',
|
||||
|
||||
settings_desc_session_endless_scroll: '有効にすると、上にスクロールしたとき古いメッセージを自動で読み込みます。無効の場合は古いメッセージボタンを使います。',
|
||||
settings_label_activity_feed_expanded_default: 'アクティビティフィードをデフォルトで展開',
|
||||
settings_desc_activity_feed_expanded_default: '新しいアクティビティの詳細を自動的に展開し、ツールやモデルの進捗をクリックなしで確認できます。ターンごとの手動折りたたみ/展開は優先されます。',
|
||||
settings_label_worklog_details_expanded_default: 'Worklog の詳細を自動的に開く',
|
||||
settings_desc_worklog_details_expanded_default: '有効にすると、新しい Worklog の詳細が展開された状態で始まり、ツール、Thinking、進捗カードをクリックなしで確認できます。無効の場合、Worklog の詳細はデフォルトで折りたたまれます。ターンごとの手動折りたたみ/展開は優先されます。',
|
||||
|
||||
settings_label_tab_visibility: 'サイドバータブ',
|
||||
settings_desc_tab_visibility: 'サイドバーとレールに表示するタブを選択します。チャットと設定は常に表示されます。',
|
||||
@@ -5371,8 +5371,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: 'Загружать старые сообщения при прокрутке вверх',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Если включено, старые сообщения загружаются автоматически при прокрутке вверх. Если выключено, используйте кнопку загрузки старых сообщений.',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: 'Вкладки боковой панели',
|
||||
settings_desc_tab_visibility: 'Выберите, какие вкладки отображаются на боковой панели и в рейле. Чат и настройки всегда видны.',
|
||||
@@ -6692,8 +6692,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: 'Cargar mensajes antiguos al desplazarse hacia arriba',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Si está activado, los mensajes antiguos se cargan automáticamente al desplazarte hacia arriba. Si está desactivado, usa el botón de mensajes antiguos.',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: 'Pestañas de la barra lateral',
|
||||
settings_desc_tab_visibility: 'Elige qué pestañas aparecen en la barra lateral y el rail. Chat y Configuración siempre están visibles.',
|
||||
@@ -7713,8 +7713,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: 'Ältere Nachrichten beim Hochscrollen laden',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Wenn aktiviert, werden ältere Nachrichten beim Hochscrollen automatisch geladen. Wenn deaktiviert, nutzt du den Button für ältere Nachrichten.',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: 'Seitenleiste-Tabs',
|
||||
settings_desc_tab_visibility: 'Wähle, welche Tabs in der Seitenleiste und im Rail angezeigt werden. Ziehe die Chips, um die Reihenfolge zu ändern. Chat und Einstellungen sind immer sichtbar.',
|
||||
@@ -9369,8 +9369,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: '向上滚动时加载更早的消息',
|
||||
|
||||
settings_desc_session_endless_scroll: '启用后,向上滚动时会自动加载更早的消息。禁用时请使用加载更早消息按钮。',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: '侧边栏标签',
|
||||
settings_desc_tab_visibility: '选择在侧边栏和导航栏中显示哪些标签。聊天和设置始终可见。',
|
||||
@@ -10087,8 +10087,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: '向上捲動時載入較早訊息',
|
||||
|
||||
settings_desc_session_endless_scroll: '啟用後,向上捲動時會自動載入較早訊息。停用時請使用載入較早訊息按鈕。',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: '側邊欄標籤',
|
||||
settings_desc_tab_visibility: '選擇在側邊欄和導覽列中顯示哪些標籤。聊天和設定始終可見。',
|
||||
@@ -11373,8 +11373,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: 'Carregar mensagens antigas ao rolar para cima',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Quando ativado, mensagens antigas carregam automaticamente ao rolar para cima. Quando desativado, use o botão de mensagens antigas.',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: 'Abas da barra lateral',
|
||||
settings_desc_tab_visibility: 'Escolha quais abas aparecem na barra lateral e no rail. Chat e Configurações estão sempre visíveis.',
|
||||
@@ -12670,8 +12670,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: '위로 스크롤할 때 이전 메시지 불러오기',
|
||||
|
||||
settings_desc_session_endless_scroll: '활성화하면 위로 스크롤할 때 이전 메시지를 자동으로 불러옵니다. 비활성화하면 이전 메시지 버튼을 사용합니다.',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: '사이드바 탭',
|
||||
settings_desc_tab_visibility: '사이드바와 레일에 표시할 탭을 선택하세요. 채팅과 설정은 항상 표시됩니다.',
|
||||
@@ -13984,8 +13984,8 @@ const LOCALES = {
|
||||
settings_desc_terminal_auto_expand: 'Développer automatiquement le panneau de terminal réduit lorsqu\'une commande en cours d\'exécution émet une nouvelle sortie.',
|
||||
settings_label_session_endless_scroll: 'Charger les anciens messages en faisant défiler vers le haut',
|
||||
settings_desc_session_endless_scroll: 'Lorsqu\'ils sont activés, les anciens messages se chargent automatiquement lorsque vous faites défiler vers le haut. Lorsqu\'il est désactivé, utilisez le bouton des messages plus anciens.',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: 'Onglets de la barre latérale',
|
||||
settings_desc_tab_visibility: 'Choisissez quels onglets apparaissent dans la barre latérale et le rail. Chat et Paramètres sont toujours visibles.',
|
||||
@@ -15399,8 +15399,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: 'Yukarı kaydırırken eski mesajları yükle',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Etkinleştirildiğinde, yukarı doğru kaydırdığınızda eski mesajlar otomatik olarak yüklenir. Devre dışı bırakıldığında eski mesajlar düğmesini kullanın.',
|
||||
settings_label_activity_feed_expanded_default: 'Expand activity feed by default',
|
||||
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
|
||||
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
|
||||
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
|
||||
|
||||
settings_label_tab_visibility: 'Kenar çubuğu sekmeleri',
|
||||
settings_desc_tab_visibility: 'Kenar çubuğunda ve rayda hangi sekmelerin görüneceğini seçin. Sohbet ve Ayarlar her zaman görünür durumdadır.',
|
||||
@@ -16806,8 +16806,8 @@ const LOCALES = {
|
||||
settings_label_session_endless_scroll: 'Ładuj starsze wiadomości podczas przewijania w górę',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Gdy ta opcja jest włączona, starsze wiadomości ładują się automatycznie przy przewijaniu w górę. Gdy jest wyłączona, użyj przycisku wczytywania starszych wiadomości.',
|
||||
settings_label_activity_feed_expanded_default: 'Rozwiń kanał aktywności domyślnie',
|
||||
settings_desc_activity_feed_expanded_default: 'Rozwijaj nowe szczegóły Aktywności automatycznie, aby postęp narzędzi i modeli był widoczny bez dodatkowego kliknięcia. Ręczne decyzje o zwinięciu/rozwinięciu w danej turze mają pierwszeństwo.',
|
||||
settings_label_worklog_details_expanded_default: 'Otwieraj szczegóły Worklog automatycznie',
|
||||
settings_desc_worklog_details_expanded_default: 'Gdy ta opcja jest włączona, nowe szczegóły Worklog zaczynają rozwinięte, dzięki czemu karty narzędzi, Thinking i postępu są widoczne bez dodatkowego kliknięcia. Gdy jest wyłączona, szczegóły Worklog pozostają domyślnie zwinięte. Ręczne decyzje o zwinięciu/rozwinięciu w danej turze mają pierwszeństwo.',
|
||||
|
||||
settings_label_tab_visibility: 'Karty paska bocznego',
|
||||
settings_desc_tab_visibility: 'Wybierz, które karty pojawiają się na pasku bocznym i szynie. Przeciągnij elementy, aby zmienić ich kolejność. Czat i Ustawienia są zawsze widoczne.',
|
||||
|
||||
@@ -1006,10 +1006,10 @@
|
||||
</div>
|
||||
<div class="settings-field" style="margin-top:8px">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsActivityFeedExpandedDefault" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_activity_feed_expanded_default">Expand activity feed by default</span>
|
||||
<input type="checkbox" id="settingsWorklogDetailsExpandedDefault" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_worklog_details_expanded_default">Open Worklog details automatically</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_activity_feed_expanded_default">Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_worklog_details_expanded_default">When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label id="tabVisibilityLabel" data-i18n="settings_label_tab_visibility">Sidebar tabs</label>
|
||||
@@ -1160,13 +1160,6 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_fade_text_effect">Fade newly streamed words in while the assistant is responding. Similar to OpenWebUI; off by default for maximum performance.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSimplifiedToolCalling" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span>Compact tool activity</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show thinking and tool calls as compact inline activity while preserving the agent timeline.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsTerminalAutoExpand" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
|
||||
@@ -1589,6 +1589,72 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
function _closeSource(source){
|
||||
closeLiveStream(activeSid, streamId, source);
|
||||
}
|
||||
function _clearStreamEndRecovery(){
|
||||
if(_streamEndRecoveryTimer){
|
||||
clearTimeout(_streamEndRecoveryTimer);
|
||||
_streamEndRecoveryTimer=null;
|
||||
}
|
||||
_pendingStreamEndRecovery=false;
|
||||
_streamEndRecoveryAttempts=0;
|
||||
}
|
||||
function _liveStreamEndScenePresent(){
|
||||
if(assistantText||assistantRow) return true;
|
||||
if(String(liveReasoningText||reasoningText||'').trim()) return true;
|
||||
const inflight=INFLIGHT[activeSid];
|
||||
if(inflight&&Array.isArray(inflight.toolCalls)&&inflight.toolCalls.length) return true;
|
||||
if(!_isActiveSession()||typeof document==='undefined') return false;
|
||||
const turn=$('liveAssistantTurn');
|
||||
return !!(turn&&turn.querySelector(
|
||||
'[data-live-assistant="1"],'+
|
||||
'.live-worklog[data-live-worklog-shell="1"],'+
|
||||
'.tool-card-row[data-live-tid],'+
|
||||
'.agent-activity-thinking[data-thinking-active="1"]'
|
||||
));
|
||||
}
|
||||
function _scheduleStreamEndRecovery(source, delay=180){
|
||||
if(_streamEndRecoveryTimer) clearTimeout(_streamEndRecoveryTimer);
|
||||
_pendingStreamEndRecovery=true;
|
||||
_streamEndRecoveryTimer=setTimeout(()=>{void _runStreamEndRecovery(source);},delay);
|
||||
}
|
||||
function _finalizeStreamEndFallback(source){
|
||||
_clearStreamEndRecovery();
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_terminalStateReached=true;
|
||||
_streamFinalized=true;
|
||||
_cancelAnimationFramePendingStreamRender();
|
||||
_streamFadeCleanupReduceMotionListener();
|
||||
_smdEndParser();
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
_clearOwnerInflightState();
|
||||
_clearApprovalForOwner();
|
||||
_clearClarifyForOwner('terminal');
|
||||
if(_isActiveSession()){
|
||||
S.activeStreamId=null;
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
renderMessages({preserveScroll:true});
|
||||
}
|
||||
renderSessionList();
|
||||
_setActivePaneIdleIfOwner();
|
||||
_closeSource(source);
|
||||
}
|
||||
async function _runStreamEndRecovery(source){
|
||||
if(_streamFinalized || _terminalStateReached || !_pendingStreamEndRecovery){
|
||||
_clearStreamEndRecovery();
|
||||
return;
|
||||
}
|
||||
_streamEndRecoveryTimer=null;
|
||||
const status=await _restoreSettledSession(source,{status:true});
|
||||
if(status==='restored'){
|
||||
_clearStreamEndRecovery();
|
||||
return;
|
||||
}
|
||||
if(status==='active'&&_streamEndRecoveryAttempts<10){
|
||||
_streamEndRecoveryAttempts+=1;
|
||||
_scheduleStreamEndRecovery(source,200);
|
||||
return;
|
||||
}
|
||||
_finalizeStreamEndFallback(source);
|
||||
}
|
||||
function _stripLiveVisibleAssistantEchoFromThinking(text, snippets){
|
||||
let out=String(text||'');
|
||||
(Array.isArray(snippets)?snippets:[]).forEach(snippet=>{
|
||||
@@ -1739,6 +1805,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
let _reconnectAttempted=false;
|
||||
let _terminalStateReached=false;
|
||||
let _deferredStreamRecoveryBound=false;
|
||||
let _pendingStreamEndRecovery=false;
|
||||
let _streamEndRecoveryTimer=null;
|
||||
let _streamEndRecoveryAttempts=0;
|
||||
|
||||
function _pageHiddenForStreamError(){
|
||||
return (typeof document!=='undefined'&&document.visibilityState==='hidden')||
|
||||
@@ -3042,6 +3111,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('done',e=>{
|
||||
if(_streamFinalized) return;
|
||||
_clearStreamEndRecovery();
|
||||
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
|
||||
// Set _streamFinalized IMMEDIATELY — before any fade delay. Without this,
|
||||
// a stream_end event arriving during the fade window sees
|
||||
@@ -3266,27 +3336,30 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_closeSource(source);
|
||||
return;
|
||||
}
|
||||
_clearStreamEndRecovery();
|
||||
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
|
||||
_terminalStateReached=true;
|
||||
try{
|
||||
const d=JSON.parse(e.data||'{}');
|
||||
if((d.session_id||activeSid)!==activeSid) return;
|
||||
}catch(_){}
|
||||
if(S.activeStreamId===streamId && _liveStreamEndScenePresent()){
|
||||
_scheduleStreamEndRecovery(source);
|
||||
return;
|
||||
}
|
||||
// Some replay/journal paths can deliver stream_end without a preceding
|
||||
// done event. In that case closing the EventSource is not enough: the
|
||||
// live DOM/inflight state remains projected and can duplicate Thinking or
|
||||
// assistant content until a later session switch. Settle from the persisted
|
||||
// session before closing so the pane converges on canonical state.
|
||||
if(await _restoreSettledSession(source)){
|
||||
const status=await _restoreSettledSession(source,{status:true});
|
||||
if(status==='restored'){
|
||||
return;
|
||||
}
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_streamFinalized=true;
|
||||
_cancelAnimationFramePendingStreamRender();
|
||||
_streamFadeCleanupReduceMotionListener();
|
||||
_smdEndParser();
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
_closeSource(source);
|
||||
if(status==='active'&&S.activeStreamId===streamId){
|
||||
_scheduleStreamEndRecovery(source,200);
|
||||
return;
|
||||
}
|
||||
_finalizeStreamEndFallback(source);
|
||||
});
|
||||
|
||||
source.addEventListener('pending_steer_leftover',e=>{
|
||||
@@ -3398,6 +3471,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('apperror',e=>{
|
||||
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
|
||||
_clearStreamEndRecovery();
|
||||
_terminalStateReached=true;
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_streamFinalized=true;
|
||||
@@ -3494,6 +3568,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_closeSource(source);
|
||||
return;
|
||||
}
|
||||
// #3885: if a stream_end recovery is in flight, don't start a competing
|
||||
// reconnect — recovery polls server state and owns the terminal decision
|
||||
// (else its exhaustion could mute a freshly reconnected stream). Opus stage-LK.
|
||||
if(_pendingStreamEndRecovery){
|
||||
_closeSource(source);
|
||||
return;
|
||||
}
|
||||
if(typeof recordClientSSEError==='function') recordClientSSEError('chat-response',{ready_state:source?source.readyState:null,session_id:activeSid,stream_id:streamId,reason:'chat EventSource.onerror'});
|
||||
source.close();
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
@@ -3541,6 +3622,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('cancel',e=>{
|
||||
if(_bailOutOfTerminalEventsFromStaleStream(source)) return;
|
||||
_clearStreamEndRecovery();
|
||||
_terminalStateReached=true;
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_streamFinalized=true;
|
||||
@@ -3631,19 +3713,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
window._carryForwardEphemeralTurnFields=_carryForwardEphemeralTurnFields;
|
||||
}
|
||||
|
||||
async function _restoreSettledSession(source){
|
||||
async function _restoreSettledSession(source, options=null){
|
||||
const returnStatus=!!(options&&options.status);
|
||||
if(_isActiveSession() && S.activeStreamId!==streamId){
|
||||
_closeSource(source);
|
||||
return false;
|
||||
return returnStatus?'stale':false;
|
||||
}
|
||||
try{
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
|
||||
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
|
||||
// we were awaiting the network roundtrip, bail out — done already settled.
|
||||
if(_streamFinalized) return true;
|
||||
if(_streamFinalized) return returnStatus?'restored':true;
|
||||
const session=data&&data.session;
|
||||
if(!session) return false;
|
||||
if(session.active_stream_id||session.pending_user_message) return false;
|
||||
if(!session) return returnStatus?'missing':false;
|
||||
if(session.active_stream_id||session.pending_user_message) return returnStatus?'active':false;
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_streamFinalized=true;
|
||||
_cancelAnimationFramePendingStreamRender();
|
||||
@@ -3701,9 +3784,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(_isActiveSession()) _queueDrainSid=activeSid;
|
||||
renderSessionList();
|
||||
_setActivePaneIdleIfOwner();
|
||||
return true;
|
||||
return returnStatus?'restored':true;
|
||||
}catch(_){
|
||||
return false;
|
||||
return returnStatus?'error':false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3712,6 +3795,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_closeSource(source);
|
||||
return;
|
||||
}
|
||||
_clearStreamEndRecovery();
|
||||
// Opus review Q1: mirror done/apperror/cancel finalization so any pending rAF
|
||||
// cannot fire after renderMessages() has settled the DOM with the error message.
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
|
||||
@@ -6205,13 +6205,15 @@ function _applyTtsEnabled(enabled){
|
||||
}
|
||||
|
||||
function _appearancePayloadFromUi(){
|
||||
const worklogDetailsExpanded=!!($('settingsWorklogDetailsExpandedDefault')||{}).checked;
|
||||
return {
|
||||
theme: ($('settingsTheme')||{}).value || localStorage.getItem('hermes-theme') || 'dark',
|
||||
skin: ($('settingsSkin')||{}).value || localStorage.getItem('hermes-skin') || 'default',
|
||||
font_size: ($('settingsFontSize')||{}).value || localStorage.getItem('hermes-font-size') || 'default',
|
||||
session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked,
|
||||
session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked,
|
||||
activity_feed_expanded_default: !!($('settingsActivityFeedExpandedDefault')||{}).checked,
|
||||
worklog_details_expanded_default: worklogDetailsExpanded,
|
||||
activity_feed_expanded_default: worklogDetailsExpanded,
|
||||
hidden_tabs: _getHiddenTabs(),
|
||||
tab_order: _getTabOrder(),
|
||||
};
|
||||
@@ -6266,8 +6268,15 @@ async function _autosaveAppearanceSettings(payload){
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
}
|
||||
window._sessionEndlessScrollEnabled=!!(saved&&saved.session_endless_scroll);
|
||||
if(saved&&Object.prototype.hasOwnProperty.call(saved,'activity_feed_expanded_default')){
|
||||
window._activityFeedExpandedDefault=!!saved.activity_feed_expanded_default;
|
||||
if(saved&&payload&&Object.prototype.hasOwnProperty.call(payload,'worklog_details_expanded_default')&&(
|
||||
Object.prototype.hasOwnProperty.call(saved,'worklog_details_expanded_default') ||
|
||||
Object.prototype.hasOwnProperty.call(saved,'activity_feed_expanded_default')
|
||||
)){
|
||||
window._worklogDetailsExpandedByDefault=!!(
|
||||
Object.prototype.hasOwnProperty.call(saved,'worklog_details_expanded_default')
|
||||
? saved.worklog_details_expanded_default
|
||||
: saved.activity_feed_expanded_default
|
||||
);
|
||||
}
|
||||
_setAppearanceAutosaveStatus('saved');
|
||||
}catch(e){
|
||||
@@ -6300,8 +6309,6 @@ function _preferencesPayloadFromUi(){
|
||||
if(showTpsCb) payload.show_tps=showTpsCb.checked;
|
||||
const fadeTextCb=$('settingsFadeTextEffect');
|
||||
if(fadeTextCb) payload.fade_text_effect=fadeTextCb.checked;
|
||||
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
|
||||
if(simplifiedToolCb) payload.simplified_tool_calling=simplifiedToolCb.checked;
|
||||
const terminalAutoExpandCb=$('settingsTerminalAutoExpand');
|
||||
if(terminalAutoExpandCb) payload.terminal_auto_expand_on_output=terminalAutoExpandCb.checked;
|
||||
const apiRedactCb=$('settingsApiRedact');
|
||||
@@ -6378,11 +6385,6 @@ function _schedulePreferencesAutosave(){
|
||||
async function _autosavePreferencesSettings(payload){
|
||||
try{
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
||||
if(payload&&payload.simplified_tool_calling!==undefined){
|
||||
window._simplifiedToolCalling=(saved&&saved.simplified_tool_calling!==false);
|
||||
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
|
||||
if(typeof renderMessages==='function') renderMessages();
|
||||
}
|
||||
if(payload&&payload.terminal_auto_expand_on_output!==undefined){
|
||||
window._terminalAutoExpandOnOutput=!!(saved&&saved.terminal_auto_expand_on_output);
|
||||
}
|
||||
@@ -6493,12 +6495,16 @@ async function loadSettingsPanel(){
|
||||
_scheduleAppearanceAutosave();
|
||||
};
|
||||
}
|
||||
const activityExpandedCb=$('settingsActivityFeedExpandedDefault');
|
||||
if(activityExpandedCb){
|
||||
activityExpandedCb.checked=!!settings.activity_feed_expanded_default;
|
||||
window._activityFeedExpandedDefault=activityExpandedCb.checked;
|
||||
activityExpandedCb.onchange=function(){
|
||||
window._activityFeedExpandedDefault=this.checked;
|
||||
const worklogDetailsExpandedCb=$('settingsWorklogDetailsExpandedDefault');
|
||||
if(worklogDetailsExpandedCb){
|
||||
const worklogDetailsExpanded=Object.prototype.hasOwnProperty.call(settings,'worklog_details_expanded_default')
|
||||
? settings.worklog_details_expanded_default
|
||||
: settings.activity_feed_expanded_default;
|
||||
worklogDetailsExpandedCb.checked=!!worklogDetailsExpanded;
|
||||
window._worklogDetailsExpandedByDefault=worklogDetailsExpandedCb.checked;
|
||||
worklogDetailsExpandedCb.onchange=function(){
|
||||
window._worklogDetailsExpandedByDefault=this.checked;
|
||||
if(typeof _applyWorklogDetailsExpandedDefault==='function') _applyWorklogDetailsExpandedDefault();
|
||||
_scheduleAppearanceAutosave();
|
||||
};
|
||||
}
|
||||
@@ -6625,8 +6631,6 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
const fadeTextCb=$('settingsFadeTextEffect');
|
||||
if(fadeTextCb){fadeTextCb.checked=!!settings.fade_text_effect;window._fadeTextEffect=fadeTextCb.checked;fadeTextCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
|
||||
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const terminalAutoExpandCb=$('settingsTerminalAutoExpand');
|
||||
if(terminalAutoExpandCb){terminalAutoExpandCb.checked=!!settings.terminal_auto_expand_on_output;window._terminalAutoExpandOnOutput=terminalAutoExpandCb.checked;terminalAutoExpandCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const apiRedactCb=$('settingsApiRedact');
|
||||
@@ -7737,7 +7741,7 @@ function _applySavedSettingsUi(saved, body, opts){
|
||||
window._notificationsEnabled=body.notifications_enabled;
|
||||
window._whatsNewSummaryEnabled=!!body.whats_new_summary_enabled;
|
||||
window._showThinking=body.show_thinking!==false;
|
||||
window._simplifiedToolCalling=body.simplified_tool_calling!==false;
|
||||
window._simplifiedToolCalling=true;
|
||||
window._terminalAutoExpandOnOutput=!!body.terminal_auto_expand_on_output;
|
||||
window._sessionJumpButtonsEnabled=!!body.session_jump_buttons;
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
@@ -8079,7 +8083,6 @@ async function saveSettings(andClose){
|
||||
body.show_quota_chip=showQuotaChip===true;
|
||||
body.show_tps=showTps;
|
||||
body.fade_text_effect=fadeTextEffect;
|
||||
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
|
||||
body.terminal_auto_expand_on_output=!!($('settingsTerminalAutoExpand')||{}).checked;
|
||||
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
|
||||
210
static/ui.js
210
static/ui.js
@@ -3509,38 +3509,86 @@ function renderMd(raw){
|
||||
s=s.replace(/^---+$/gm,'<hr>');
|
||||
// (Blockquotes are handled by the pre-pass at the top of renderMd, before
|
||||
// fence_stash. The per-line passes below never see > prefixes.)
|
||||
// B8: improved list handling supporting up to 2 levels of indentation
|
||||
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
|
||||
const lines=block.trimEnd().split('\n');
|
||||
let html='<ul>';
|
||||
for(const l of lines){
|
||||
const indent=/^ {2,}/.test(l);
|
||||
const text=l.replace(/^ {0,4}[-*+] /,'');
|
||||
let _ih;
|
||||
if(/^\[x\] /i.test(text)) _ih='<span class="task-done">✅</span> '+inlineMd(text.slice(4));
|
||||
else if(/^\[ \] /.test(text)) _ih='<span class="task-todo">☐</span> '+inlineMd(text.slice(4));
|
||||
else _ih=inlineMd(text);
|
||||
if(indent) html+=`<li style="margin-left:16px">${_ih}</li>`;
|
||||
else html+=`<li>${_ih}</li>`;
|
||||
function _renderListBlock(lines, ordered){
|
||||
const marker=ordered?'\\d+\\. ':'[-*+] ';
|
||||
let html=ordered?'<ol>':'<ul>';
|
||||
let item=null;
|
||||
const flush=()=>{
|
||||
if(!item) return;
|
||||
const body=item.parts.join('\n').trim();
|
||||
const text=body;
|
||||
let inner;
|
||||
if(!ordered && /^\[x\] /i.test(text)) inner='<span class="task-done">✅</span> '+inlineMd(text.slice(4));
|
||||
else if(!ordered && /^\[ \] /.test(text)) inner='<span class="task-todo">☐</span> '+inlineMd(text.slice(4));
|
||||
else inner=inlineMd(text);
|
||||
const valueAttr=item.value!==null?` value="${item.value}"`:'';
|
||||
const styleAttr=item.indent?` style="margin-left:16px"`:'';
|
||||
html+=`<li${valueAttr}${styleAttr}>${inner}</li>`;
|
||||
item=null;
|
||||
};
|
||||
for(const raw of lines){
|
||||
const line=String(raw||'');
|
||||
const nested=line.match(new RegExp(`^ {2,}(${marker})(.*)$`));
|
||||
if(nested){
|
||||
flush();
|
||||
item={indent:true,value:ordered?parseInt(nested[1],10):null,parts:[nested[2]]};
|
||||
continue;
|
||||
}
|
||||
const top=line.match(new RegExp(`^(?: )?(${marker})(.*)$`));
|
||||
if(top){
|
||||
flush();
|
||||
item={indent:false,value:ordered?parseInt(top[1],10):null,parts:[top[2]]};
|
||||
continue;
|
||||
}
|
||||
if(!item) continue;
|
||||
item.parts.push(line.replace(/^ {2,}/,'').trim());
|
||||
}
|
||||
return html+'</ul>';
|
||||
});
|
||||
// Ordered lists: use value= on each <li> so the correct number is preserved
|
||||
// even when blank lines between items cause the paragraph splitter to place
|
||||
// each item in its own <ol> container — without value= every <ol> restarts
|
||||
// at 1, producing "1. 1. 1." instead of "1. 2. 3." (#886).
|
||||
s=s.replace(/((?:^(?: )?\d+\. .+\n?)+)/gm,block=>{
|
||||
const lines=block.trimEnd().split('\n');
|
||||
let html='<ol>';
|
||||
for(const l of lines){
|
||||
const numMatch=l.match(/^\s*(\d+)\. /);
|
||||
const num=numMatch?parseInt(numMatch[1],10):null;
|
||||
const text=l.replace(/^ {0,4}\d+\. /,'');
|
||||
const valAttr=num!==null?` value="${num}"`:'';
|
||||
html+=`<li${valAttr}>${inlineMd(text)}</li>`;
|
||||
flush();
|
||||
return html+(ordered?'</ol>':'</ul>');
|
||||
}
|
||||
function _renderLists(src, ordered){
|
||||
const lines=src.split('\n');
|
||||
const out=[];
|
||||
const topRe=ordered?/^(?: )?\d+\. /:/^(?: )?[-*+] /;
|
||||
const nestedRe=ordered?/^ {2,}\d+\. /:/^ {2,}[-*+] /;
|
||||
const contRe=/^ {2,}\S/;
|
||||
let i=0;
|
||||
while(i<lines.length){
|
||||
if(!topRe.test(lines[i])){
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const block=[lines[i]];
|
||||
i++;
|
||||
while(i<lines.length){
|
||||
const line=lines[i];
|
||||
if(topRe.test(line)||nestedRe.test(line)||contRe.test(line)){
|
||||
block.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if(!line.trim()){
|
||||
const next=lines[i+1]||'';
|
||||
if(topRe.test(next)||nestedRe.test(next)||contRe.test(next)){
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
out.push(_renderListBlock(block,ordered));
|
||||
}
|
||||
return html+'</ol>';
|
||||
});
|
||||
return out.join('\n');
|
||||
}
|
||||
// Preserve continuation lines, nested indentation, and LaTeX placeholder lines
|
||||
// inside list items without changing the wider markdown pipeline.
|
||||
s=_renderLists(s,false);
|
||||
// Ordered-list parsing intentionally runs on the post-unordered string; the
|
||||
// unordered pass emits <ul> HTML that cannot satisfy the ordered-item regex.
|
||||
// Keep continuation lines attached to their item and preserve explicit
|
||||
// numbering via value= even when blank lines split the markdown.
|
||||
s=_renderLists(s,true);
|
||||
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
|
||||
// NOTE: table pass runs BEFORE outer link pass so [label](url) in table cells
|
||||
// is handled by inlineMd() only — prevents double-linking.
|
||||
@@ -6410,10 +6458,30 @@ function _worklogReasoningTextFromMessage(m, rawIdx, toolCallAssistantIdxs, visi
|
||||
const visibleTexts=Array.isArray(turnVisibleContents)?turnVisibleContents:[];
|
||||
return _stripVisibleAssistantEchoFromThinking(thinkingText, visibleContent, turnFinalVisibleContent, ...visibleTexts);
|
||||
}
|
||||
function _worklogDetailsExpandedDefault(){
|
||||
return window._worklogDetailsExpandedByDefault===true;
|
||||
}
|
||||
function _applyWorklogDetailsExpandedDefault(root){
|
||||
const scope=root&&root.querySelectorAll?root:document;
|
||||
const open=_worklogDetailsExpandedDefault();
|
||||
scope.querySelectorAll('.thinking-card').forEach(card=>{
|
||||
card.classList.toggle('open', open);
|
||||
});
|
||||
scope.querySelectorAll('.tool-card').forEach(card=>{
|
||||
if(card.querySelector('.tool-card-detail')) card.classList.toggle('open', open);
|
||||
});
|
||||
scope.querySelectorAll('.tool-group[data-tool-worklog-tool-group="1"],.tool-worklog-tool-group').forEach(group=>{
|
||||
group.classList.toggle('open', open);
|
||||
group.classList.toggle('tool-worklog-tool-group-collapsed', !open);
|
||||
const summary=group.querySelector('.tool-group-head,.tool-worklog-tool-group-head');
|
||||
if(summary) summary.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
}
|
||||
function _thinkingCardHtml(text, open){
|
||||
const clean=_sanitizeThinkingDisplayText(text);
|
||||
const copyBtn=`<button class="thinking-copy-btn" onclick="event.stopPropagation();_copyThinkingText(this)" title="${t('copy')}" aria-label="${t('copy')}">${li('copy',12)}</button>`;
|
||||
const classes=`thinking-card${open?' open':''}`;
|
||||
const shouldOpen=!!open||_worklogDetailsExpandedDefault();
|
||||
const classes=`thinking-card${shouldOpen?' open':''}`;
|
||||
return `<div class="${classes}"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-btn-row">${copyBtn}<span class="thinking-card-toggle">${li('chevron-right',12)}</span></span></div><div class="thinking-card-body"><pre>${esc(clean)}</pre></div></div>`;
|
||||
}
|
||||
function isSimplifiedToolCalling(){
|
||||
@@ -6769,7 +6837,7 @@ function ensureActivityGroup(inner, opts){
|
||||
if(!group){
|
||||
group=document.createElement('div');
|
||||
let collapsed=opts.collapsed!==false;
|
||||
if(window._activityFeedExpandedDefault===true) collapsed=false;
|
||||
if(window._worklogDetailsExpandedByDefault===true) collapsed=false;
|
||||
const savedState=_readActivityDisclosureState(activityKey);
|
||||
// Restore the user's explicit expand intent when recreating the live
|
||||
// activity group within the same turn (#1298), then let persisted chat/turn
|
||||
@@ -7927,6 +7995,23 @@ function renderMessages(options){
|
||||
return m._statusCard||msgContent(m)||m.attachments?.length;
|
||||
});
|
||||
$('emptyState').style.display=(vis.length||preservedCompressionTaskMessages.length)?'none':'';
|
||||
// Mid-stream flicker fix (#3877): when a renderMessages() rebuild is reached
|
||||
// while THIS session is actively streaming (e.g. the clarify-response echo at
|
||||
// messages.js, or a CLI-import refresh), the `inner.innerHTML=''` below detaches
|
||||
// the live `#liveAssistantTurn` node — and the smd parser keeps writing into
|
||||
// that now-orphaned node, so the streamed text vanishes until the next stream
|
||||
// event rebuilds the turn ("disappears, then reappears"). Capture the live
|
||||
// turn's actual DOM node (not its HTML — the parser holds a live reference into
|
||||
// it) so it can be re-attached after the rebuild, keeping the parser target
|
||||
// connected and the streamed text visible. Only for the streaming session's own
|
||||
// live turn; never affects settled transcripts.
|
||||
let _preservedLiveTurn=null;
|
||||
if(sid&&INFLIGHT[sid]){
|
||||
const _lt=document.getElementById('liveAssistantTurn');
|
||||
if(_lt&&(!_lt.dataset||!_lt.dataset.sessionId||_lt.dataset.sessionId===sid)){
|
||||
_preservedLiveTurn=_lt;
|
||||
}
|
||||
}
|
||||
inner.innerHTML='';
|
||||
const compressionNode=compressionState?_compressionCardsNode(compressionState):null;
|
||||
const {message:referenceMessage, rawIdx:referenceMessageRawIdx}=_latestCompressionReferenceMessage(
|
||||
@@ -8240,6 +8325,29 @@ function renderMessages(options){
|
||||
seg.setAttribute('data-live-assistant','1');
|
||||
}
|
||||
if(_ERR_MSG_RE.test(String(content||'').trim())) seg.dataset.error='1';
|
||||
// A turn whose visible content is empty but which carries a separate
|
||||
// `reasoning` field (e.g. a run-journal-recovered anchor: empty content +
|
||||
// reasoning + `_recovered_from_run_journal`) extracts NO inline thinkingText
|
||||
// and would render no Thinking Card at all — collapsing to an empty hidden
|
||||
// anchor. A session made entirely of such rows then paints blank (only date
|
||||
// separators) — the #3875 reporter's exact case (Compact tool activity OFF,
|
||||
// i.e. legacy mode). Surface the message's reasoning payload as the Thinking
|
||||
// Card source for these empty-content turns so the turn is never blank.
|
||||
//
|
||||
// LEGACY-MODE ONLY (!isSimplifiedToolCalling()): the simplified/Worklog path
|
||||
// already derives reasoning above (line ~8149 via
|
||||
// _worklogReasoningTextFromMessage, which strips an exact visible-answer echo
|
||||
// so reasoning duplicating a sibling answer is not re-shown). Repopulating the
|
||||
// raw reasoning here would bypass that echo-strip and re-render the duplicate
|
||||
// as a Worklog Thinking card (Codex gate catch). In legacy mode there is no
|
||||
// Worklog folding, so the raw payload is the correct Thinking-card source.
|
||||
// Stays OUT of the inline-content `thinkingText` extraction block (#2565) and
|
||||
// only fires for empty-content/no-inline-thinking turns, so answer-bearing
|
||||
// messages are unchanged.
|
||||
if(!isUser&&!m._live&&!isSimplifiedToolCalling()&&!thinkingText&&!String(content||'').trim()&&!filesHtml&&!statusHtml){
|
||||
const _reasoningPayload=_assistantReasoningPayloadText(m);
|
||||
if(_reasoningPayload) thinkingText=_reasoningPayload;
|
||||
}
|
||||
if(thinkingText&&window._showThinking!==false){
|
||||
if(isSimplifiedToolCalling()&&_assistantThinkingBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs)) assistantThinking.set(rawIdx, thinkingText);
|
||||
else if(window._showThinking!==false) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText));
|
||||
@@ -8789,6 +8897,37 @@ function renderMessages(options){
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-attach the preserved live turn (#3877). The rebuild above recreated a
|
||||
// live turn from S.messages, but the live assistant message's content is empty
|
||||
// until the stream settles — so the fresh node shows no streamed text while the
|
||||
// ORIGINAL node (still referenced by the smd parser) holds the real in-progress
|
||||
// reply. If the rebuilt live turn has less streamed text than the preserved one,
|
||||
// swap the preserved node back in so the parser target stays connected and the
|
||||
// visible text never blanks. The length guard below already establishes that the
|
||||
// preserved (parser) node carries strictly MORE streamed text than the rebuilt
|
||||
// one, so a plain replaceWith is sufficient — no segment merge is needed (during
|
||||
// a live stream the rebuilt node's live content is empty/shorter, never longer,
|
||||
// and the guard skips the swap entirely when the rebuild has equal/more content).
|
||||
// No-op for a settled turn or when nothing was streaming.
|
||||
if(_preservedLiveTurn){
|
||||
const _rebuilt=document.getElementById('liveAssistantTurn');
|
||||
const _preservedLen=_liveAssistantSegmentTextLength(
|
||||
_preservedLiveTurn.querySelector('[data-live-assistant="1"]')||_preservedLiveTurn
|
||||
);
|
||||
if(_preservedLen>0){
|
||||
const _rebuiltLen=_rebuilt?_liveAssistantSegmentTextLength(
|
||||
_rebuilt.querySelector('[data-live-assistant="1"]')||_rebuilt
|
||||
):-1;
|
||||
if(_rebuiltLen<_preservedLen){
|
||||
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
|
||||
if(_rebuilt){
|
||||
_rebuilt.replaceWith(_preservedLiveTurn);
|
||||
}else{
|
||||
inner.appendChild(_preservedLiveTurn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only force-scroll when not actively streaming — mid-stream re-renders
|
||||
// (tool completion, session switch) must not override the user's scroll position.
|
||||
// scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up.
|
||||
@@ -9050,7 +9189,7 @@ function _syncToolRowsContainer(tools, isLiveWorklog){
|
||||
return;
|
||||
}
|
||||
const hasRunning=rows.some(row=>row&&row.dataset&&row.dataset.toolDone==='false');
|
||||
const shouldOpen=false;
|
||||
const shouldOpen=_worklogDetailsExpandedDefault();
|
||||
const group=document.createElement('div');
|
||||
group.className='tool-group'+(shouldOpen?' open':' tool-worklog-tool-group-collapsed');
|
||||
group.setAttribute('data-tool-worklog-tool-group','1');
|
||||
@@ -9183,7 +9322,8 @@ function buildToolCard(tc){
|
||||
const runIndicator=tc.done===false?'<span class="tool-card-running-dot"></span>':'';
|
||||
const isSubagent=tc.name==='subagent_progress';
|
||||
const isDelegation=tc.name==='delegate_task';
|
||||
const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':'');
|
||||
const openClass=hasDetail&&_worklogDetailsExpandedDefault()?' open':'';
|
||||
const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':'')+openClass;
|
||||
// Clean up legacy subagent prefixes since the Lucide icon already shows it
|
||||
let displayName=_toolDisplayName(tc);
|
||||
let previewText=_toolCardPreviewText(tc, displaySnippet);
|
||||
@@ -10221,7 +10361,7 @@ function renderKatexBlocks(container,options){
|
||||
|
||||
function _thinkingMarkup(text=''){
|
||||
const clean=_sanitizeThinkingDisplayText(text);
|
||||
const openClass=isSimplifiedToolCalling()?'':' open';
|
||||
const openClass=_worklogDetailsExpandedDefault()?' open':'';
|
||||
return (clean&&String(clean).trim())
|
||||
? `<div class="thinking-card${openClass}"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(String(clean).trim())}</pre></div></div>`
|
||||
: `<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Mirrors the structure of test_1003_appearance_autosave.py to verify the
|
||||
preferences-panel autosave pattern is wired correctly:
|
||||
|
||||
- All 15 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
|
||||
- All 14 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
|
||||
- Password field MUST still call _markSettingsDirty (security: never autosave)
|
||||
- _preferencesPayloadFromUi covers all 14 fields
|
||||
- _setPreferencesAutosaveStatus uses the shared i18n keys
|
||||
@@ -39,7 +39,6 @@ PREFERENCE_FIELDS_AUTOSAVE = [
|
||||
("settingsLanguage", "language"),
|
||||
("settingsShowTokenUsage", "show_token_usage"),
|
||||
("settingsShowTps", "show_tps"),
|
||||
("settingsSimplifiedToolCalling", "simplified_tool_calling"),
|
||||
("settingsShowCliSessions", "show_cli_sessions"),
|
||||
("settingsShowPreviousMessagingSessions", "show_previous_messaging_sessions"),
|
||||
("settingsSyncInsights", "sync_to_insights"),
|
||||
@@ -54,8 +53,8 @@ PREFERENCE_FIELDS_AUTOSAVE = [
|
||||
]
|
||||
|
||||
|
||||
def test_all_15_preference_fields_have_autosave_payload_entries():
|
||||
"""_preferencesPayloadFromUi must include all 15 preference fields."""
|
||||
def test_all_14_preference_fields_have_autosave_payload_entries():
|
||||
"""_preferencesPayloadFromUi must include all 14 preference fields."""
|
||||
block = _function_block(PANELS_JS, "_preferencesPayloadFromUi")
|
||||
for dom_id, field in PREFERENCE_FIELDS_AUTOSAVE:
|
||||
assert f"$('{dom_id}')" in block, \
|
||||
|
||||
@@ -99,9 +99,15 @@ def test_stream_end_without_done_restores_settled_session_before_closing():
|
||||
never replaces the pane with the persisted transcript when done is missing.
|
||||
"""
|
||||
body = _event_body("stream_end")
|
||||
restore_idx = body.find("_restoreSettledSession(source)")
|
||||
close_idx = body.rfind("_closeSource(source)")
|
||||
finalized_idx = body.find("_streamFinalized=true")
|
||||
restore_idx = body.find("_restoreSettledSession(source,{status:true})")
|
||||
if restore_idx == -1:
|
||||
restore_idx = body.find("_restoreSettledSession(source)")
|
||||
close_idx = body.find("_closeSource(source)", restore_idx)
|
||||
if close_idx == -1:
|
||||
close_idx = body.find("_finalizeStreamEndFallback(source)", restore_idx)
|
||||
finalized_idx = body.find("_streamFinalized=true", restore_idx)
|
||||
if finalized_idx == -1:
|
||||
finalized_idx = body.find("_finalizeStreamEndFallback(source)", restore_idx)
|
||||
assert restore_idx != -1, "stream_end handler must restore settled session when done is absent"
|
||||
assert close_idx != -1, "stream_end handler must still close the owning EventSource"
|
||||
assert restore_idx < close_idx, "restore must be attempted before closing the stream"
|
||||
@@ -113,7 +119,7 @@ def test_settled_restore_and_error_close_only_the_event_source_owner():
|
||||
restore_body = _function_body("_restoreSettledSession")
|
||||
error_body = _function_body("_handleStreamError")
|
||||
event_body = _event_body("error")
|
||||
assert "async function _restoreSettledSession(source)" in MESSAGES_JS
|
||||
assert "async function _restoreSettledSession(source, options=null)" in MESSAGES_JS
|
||||
assert "function _handleStreamError(source)" in MESSAGES_JS
|
||||
assert "_closeSource(source);" in restore_body
|
||||
assert "_closeSource(source);" in error_body
|
||||
|
||||
@@ -20,15 +20,22 @@ def get_ui_js():
|
||||
|
||||
|
||||
class TestOrderedListNumbering:
|
||||
def _ordered_list_block(self, src: str) -> str:
|
||||
start = src.find("function _renderListBlock(lines, ordered){")
|
||||
assert start != -1, "_renderListBlock helper not found in ui.js"
|
||||
end = src.find("function _renderLists(src, ordered){", start)
|
||||
assert end != -1, "_renderLists helper not found after _renderListBlock"
|
||||
return src[start:end]
|
||||
|
||||
def _ordered_list_dispatch_block(self, src: str) -> str:
|
||||
start = src.find("s=_renderLists(s,true);")
|
||||
assert start != -1, "ordered-list dispatch not found in ui.js"
|
||||
return src[max(0, start - 260):start + 80]
|
||||
|
||||
def test_li_value_attr_present_in_ordered_list_block(self):
|
||||
"""The ordered-list renderer must emit value= on each <li>."""
|
||||
src = get_ui_js()
|
||||
# Locate the ordered-list replace block
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
# Extract a window large enough to cover the whole closure (~400 chars)
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
ol_block = self._ordered_list_block(src)
|
||||
assert 'value=' in ol_block, (
|
||||
"Ordered-list block must emit value= attribute on <li> elements to "
|
||||
"preserve numbering when items are separated by blank lines (#886)"
|
||||
@@ -37,56 +44,42 @@ class TestOrderedListNumbering:
|
||||
def test_li_value_uses_parsed_number(self):
|
||||
"""The value= must be derived from parseInt of the captured digit, not hardcoded."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
ol_block = self._ordered_list_block(src)
|
||||
assert 'parseInt' in ol_block, (
|
||||
"Ordered-list block should use parseInt() to parse the list number (#886)"
|
||||
)
|
||||
|
||||
def test_numMatch_variable_present(self):
|
||||
"""The numMatch variable (or equivalent digit capture) must exist in the OL block."""
|
||||
"""The ordered-list branch must still capture digits from the markdown marker."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
# Either numMatch or a similar digit-capture variable
|
||||
assert 'numMatch' in ol_block or re.search(r'match\(/.*\\d', ol_block), (
|
||||
"Ordered-list block should capture the list item number with a regex match (#886)"
|
||||
ol_block = self._ordered_list_block(src)
|
||||
assert "const marker=ordered?'\\\\d+\\\\. ':'[-*+] ';" in ol_block, (
|
||||
"Ordered-list block should keep a digit marker pattern for numbered items (#886)"
|
||||
)
|
||||
|
||||
def test_valAttr_or_value_template_present(self):
|
||||
"""The <li> template must include the value attribute conditionally or unconditionally."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
# Either a valAttr variable or an inline value= in the template
|
||||
has_val_attr = 'valAttr' in ol_block
|
||||
ol_block = self._ordered_list_block(src)
|
||||
has_value_attr = 'valueAttr' in ol_block
|
||||
has_inline_value = re.search(r'<li.*value=', ol_block)
|
||||
assert has_val_attr or has_inline_value, (
|
||||
"Ordered-list block must have value= on <li> (via valAttr var or inline) (#886)"
|
||||
assert has_value_attr or has_inline_value, (
|
||||
"Ordered-list block must have value= on <li> (via valueAttr var or inline) (#886)"
|
||||
)
|
||||
|
||||
def test_ordered_list_comment_references_issue(self):
|
||||
"""A comment near the OL fix should reference the issue (#886) or the symptom."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
# Look at the 300 chars BEFORE the replace line for an explanatory comment
|
||||
context = src[max(0, ol_idx - 300):ol_idx]
|
||||
context = self._ordered_list_dispatch_block(src)
|
||||
has_comment = '#886' in context or '1. 1. 1.' in context or 'blank lines' in context.lower()
|
||||
assert has_comment, (
|
||||
"Expected a comment near the OL fix explaining the blank-line issue (#886)"
|
||||
)
|
||||
|
||||
def test_list_without_blank_lines_unaffected(self):
|
||||
"""A compact list (no blank lines) should still produce one <ol> with sequential items."""
|
||||
"""A compact list should still flow through the ordered-list helper."""
|
||||
src = get_ui_js()
|
||||
# Structural check: the regex still captures multi-line blocks (\\n? allows groups)
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found"
|
||||
# The \\n? quantifier that allows grouping must still be present
|
||||
assert '\\n?' in src[ol_idx:ol_idx + 80], (
|
||||
"The \\\\n? in the ordered-list regex was removed — compact lists may break"
|
||||
ol_block = self._ordered_list_dispatch_block(src)
|
||||
assert "s=_renderLists(s,true);" in ol_block, (
|
||||
"Ordered-list rendering should still route through the shared helper"
|
||||
)
|
||||
|
||||
@@ -116,6 +116,7 @@ def test_rendered_apply_patch_tool_card_html_contains_diff_lines():
|
||||
# #3336: buildToolCard now wraps diff snippets via these helpers.
|
||||
"_snippetLooksLikeDiff",
|
||||
"_colorDiffLines",
|
||||
"_worklogDetailsExpandedDefault",
|
||||
# #3544: buildToolCard stamps durable memory/skill-save flags via these.
|
||||
"_tcAction",
|
||||
"_isMemorySave",
|
||||
@@ -129,6 +130,7 @@ def test_rendered_apply_patch_tool_card_html_contains_diff_lines():
|
||||
function li(){{return '';}}
|
||||
function toolIcon(){{return '';}}
|
||||
function _toolDisplayName(tc){{return tc.name||'tool';}}
|
||||
const window={{_worklogDetailsExpandedByDefault:false}};
|
||||
// #3544: const Sets the _isMemorySave/_isSkillUpdate predicates close over
|
||||
// (extracted helpers reference these module-level constants).
|
||||
const _MEMORY_SAVE_ACTIONS=new Set(['add','replace']);
|
||||
|
||||
@@ -48,9 +48,13 @@ def test_missing_index_starts_background_rebuild_while_preserving_first_scan(mon
|
||||
assert {row["session_id"] for row in rows} == {"issue28630", "issue28631", "issue28632"}
|
||||
|
||||
thread = models._SESSION_INDEX_REBUILD_THREAD
|
||||
assert thread is not None
|
||||
thread.join(timeout=5)
|
||||
assert not thread.is_alive()
|
||||
# Fast runners can complete the background rebuild and clear the global
|
||||
# thread slot before this assertion observes it. The invariant is that the
|
||||
# first scan remains correct and the index is rebuilt, not that the transient
|
||||
# thread object is still visible.
|
||||
if thread is not None:
|
||||
thread.join(timeout=5)
|
||||
assert not thread.is_alive()
|
||||
|
||||
index = json.loads(models.SESSION_INDEX_FILE.read_text(encoding="utf-8"))
|
||||
assert {row["session_id"] for row in index} == {"issue28630", "issue28631", "issue28632"}
|
||||
|
||||
@@ -1,35 +1,141 @@
|
||||
"""Pin the full behavioral contract for the Activity expanded-default setting."""
|
||||
"""Pin the full behavioral contract for the Worklog expanded-default setting."""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _function_body(src, name):
|
||||
marker = f"function {name}"
|
||||
start = src.index(marker)
|
||||
brace = src.index("{", start)
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
ch = src[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[brace + 1:idx]
|
||||
raise AssertionError(f"function {name} body not found")
|
||||
|
||||
|
||||
def test_setting_in_defaults():
|
||||
src = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
assert '"activity_feed_expanded_default"' in src or "'activity_feed_expanded_default'" in src, \
|
||||
"activity_feed_expanded_default must exist in _SETTINGS_DEFAULTS"
|
||||
assert '"worklog_details_expanded_default"' in src or "'worklog_details_expanded_default'" in src, \
|
||||
"worklog_details_expanded_default must exist in _SETTINGS_DEFAULTS"
|
||||
# Verify default is False
|
||||
assert re.search(r'["\']activity_feed_expanded_default["\']:\s*False', src), \
|
||||
"activity_feed_expanded_default default must be False (collapsed)"
|
||||
assert re.search(r'["\']worklog_details_expanded_default["\']:\s*False', src), \
|
||||
"worklog_details_expanded_default default must be False (collapsed)"
|
||||
|
||||
|
||||
def test_setting_in_bool_keys():
|
||||
src = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
assert re.search(r'_SETTINGS_BOOL_KEYS\b.*?activity_feed_expanded_default', src, re.DOTALL), \
|
||||
"activity_feed_expanded_default must appear inside _SETTINGS_BOOL_KEYS (not just anywhere in config.py)"
|
||||
assert re.search(r'_SETTINGS_BOOL_KEYS\b.*?worklog_details_expanded_default', src, re.DOTALL), \
|
||||
"worklog_details_expanded_default must appear inside _SETTINGS_BOOL_KEYS (not just anywhere in config.py)"
|
||||
|
||||
|
||||
def test_legacy_activity_feed_setting_migrates_without_remaining_primary_semantics():
|
||||
src = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
assert '"activity_feed_expanded_default"' in src, \
|
||||
"config.py should still accept the legacy key as a migration alias"
|
||||
assert re.search(r'_SETTINGS_LEGACY_DROP_KEYS\b.*?activity_feed_expanded_default', src, re.DOTALL), \
|
||||
"The legacy Activity Feed key should be dropped from primary settings after migration"
|
||||
assert 'settings["worklog_details_expanded_default"] = bool(' in src, \
|
||||
"load_settings should migrate legacy Activity Feed values into the Worklog details key"
|
||||
assert 'settings.pop("activity_feed_expanded_default", None)' in src, \
|
||||
"save_settings should not persist the legacy Activity Feed key"
|
||||
|
||||
|
||||
def test_legacy_activity_feed_setting_migrates_on_load_and_save(monkeypatch, tmp_path):
|
||||
from api import config
|
||||
|
||||
settings_file = tmp_path / "settings.json"
|
||||
settings_file.write_text(
|
||||
json.dumps({"activity_feed_expanded_default": True}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
|
||||
|
||||
loaded = config.load_settings()
|
||||
assert loaded["worklog_details_expanded_default"] is True
|
||||
assert "activity_feed_expanded_default" not in loaded
|
||||
|
||||
saved = config.save_settings({"activity_feed_expanded_default": False})
|
||||
assert saved["worklog_details_expanded_default"] is False
|
||||
on_disk = json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
assert on_disk["worklog_details_expanded_default"] is False
|
||||
assert "activity_feed_expanded_default" not in on_disk
|
||||
|
||||
|
||||
def test_boot_initializes_window_flag():
|
||||
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
assert "activityFeedExpandedDefault" in src, \
|
||||
"boot.js must initialize window._activityFeedExpandedDefault from settings"
|
||||
assert "worklog_details_expanded_default" in src, \
|
||||
"boot.js must initialize window._worklogDetailsExpandedByDefault from settings"
|
||||
assert "s.activity_feed_expanded_default" in src, \
|
||||
"boot.js should tolerate old servers that still return the legacy key"
|
||||
|
||||
|
||||
def test_ensure_activity_group_checks_flag():
|
||||
src = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
assert "_activityFeedExpandedDefault" in src, \
|
||||
"ensureActivityGroup must check window._activityFeedExpandedDefault"
|
||||
assert "_worklogDetailsExpandedByDefault" in src, \
|
||||
"ensureActivityGroup must check window._worklogDetailsExpandedByDefault"
|
||||
|
||||
|
||||
def test_setting_controls_worklog_item_details_not_only_outer_group():
|
||||
src = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
assert "function _worklogDetailsExpandedDefault()" in src, \
|
||||
"Worklog detail cards should share a single expanded-default helper"
|
||||
|
||||
thinking_fn = _function_body(src, "_thinkingCardHtml")
|
||||
assert "_worklogDetailsExpandedDefault()" in thinking_fn, \
|
||||
"Thinking cards should respect the Worklog details default"
|
||||
|
||||
legacy_thinking_fn = _function_body(src, "_thinkingMarkup")
|
||||
assert "_worklogDetailsExpandedDefault()" in legacy_thinking_fn, \
|
||||
"Thinking update fallback markup should not overwrite the Worklog details default"
|
||||
assert "!isSimplifiedToolCalling()" not in legacy_thinking_fn, \
|
||||
"The deprecated compact-tool toggle should not keep dead branches in Thinking markup"
|
||||
|
||||
tool_fn = _function_body(src, "buildToolCard")
|
||||
assert "_worklogDetailsExpandedDefault()" in tool_fn and "openClass" in tool_fn, \
|
||||
"Tool cards should respect the Worklog details default when they have detail content"
|
||||
|
||||
grouped_tools_fn = _function_body(src, "_syncToolRowsContainer")
|
||||
assert "const shouldOpen=_worklogDetailsExpandedDefault()" in grouped_tools_fn, \
|
||||
"Multi-tool Worklog groups should respect the Worklog details default"
|
||||
|
||||
|
||||
def test_setting_toggle_applies_to_existing_worklog_details():
|
||||
ui_src = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
helper = _function_body(ui_src, "_applyWorklogDetailsExpandedDefault")
|
||||
assert "scope.querySelectorAll('.thinking-card')" in helper, \
|
||||
"Toggling the setting should update existing Thinking cards"
|
||||
assert "scope.querySelectorAll('.tool-card')" in helper and ".tool-card-detail" in helper, \
|
||||
"Toggling the setting should update existing Tool cards that have details"
|
||||
assert "data-tool-worklog-tool-group" in helper and "aria-expanded" in helper, \
|
||||
"Toggling the setting should update existing multi-tool Worklog groups"
|
||||
|
||||
panels_src = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
onchange_block = re.search(
|
||||
r"worklogDetailsExpandedCb\.onchange=function\(\)\{(?P<body>.*?)\n\s*\};",
|
||||
panels_src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert onchange_block, "Worklog detail checkbox should have an onchange handler"
|
||||
assert "_applyWorklogDetailsExpandedDefault()" in onchange_block.group("body"), \
|
||||
"Changing the Worklog detail setting should apply the new default immediately"
|
||||
|
||||
|
||||
def test_appearance_autosave_does_not_reapply_worklog_details_over_manual_state():
|
||||
panels_src = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
autosave_fn = _function_body(panels_src, "_autosaveAppearanceSettings")
|
||||
assert "_worklogDetailsExpandedByDefault" in autosave_fn, \
|
||||
"Autosave should still reconcile the stored Worklog default flag"
|
||||
assert "_applyWorklogDetailsExpandedDefault()" not in autosave_fn, \
|
||||
"Autosave responses must not overwrite per-turn manual Worklog expand/collapse choices"
|
||||
|
||||
|
||||
def test_per_turn_override():
|
||||
@@ -46,19 +152,25 @@ def test_collapse_class_applied():
|
||||
|
||||
def test_settings_checkbox_exists():
|
||||
src = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
assert "settings_label_activity_feed_expanded_default" in src, \
|
||||
"index.html must have a settings checkbox with data-i18n for activity_feed_expanded_default"
|
||||
assert "settings_label_worklog_details_expanded_default" in src, \
|
||||
"index.html must have a settings checkbox with data-i18n for worklog_details_expanded_default"
|
||||
assert "Open Worklog details automatically" in src, \
|
||||
"The setting copy should describe Worklog details, not the old Activity Feed wording"
|
||||
assert "Worklog details stay folded by default" in src, \
|
||||
"The setting copy must make the off/default state folded"
|
||||
|
||||
|
||||
def test_panels_wiring():
|
||||
src = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
assert "activity_feed_expanded_default" in src, \
|
||||
"panels.js must read/write the activity_feed_expanded_default setting"
|
||||
assert "worklog_details_expanded_default" in src, \
|
||||
"panels.js must read/write the worklog_details_expanded_default setting"
|
||||
assert "activity_feed_expanded_default: worklogDetailsExpanded" in src, \
|
||||
"panels.js should include a legacy POST alias so old servers can persist the setting during rolling updates"
|
||||
|
||||
|
||||
def test_i18n_keys():
|
||||
src = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
assert "settings_label_activity_feed_expanded_default" in src, \
|
||||
assert "settings_label_worklog_details_expanded_default" in src, \
|
||||
"i18n.js must have the label key for the setting"
|
||||
assert "settings_desc_activity_feed_expanded_default" in src, \
|
||||
assert "settings_desc_worklog_details_expanded_default" in src, \
|
||||
"i18n.js must have the description key for the setting"
|
||||
|
||||
@@ -87,3 +87,60 @@ def test_failsafe_preserves_collapsed_worklog_when_visible_answer_exists():
|
||||
assert "assistant-segment-anchor" in failsafe
|
||||
# The live turn drives its own state and must be excluded from the sweep.
|
||||
assert "liveAssistantTurn" in failsafe
|
||||
|
||||
|
||||
def test_render_surfaces_reasoning_field_for_empty_content_turn():
|
||||
"""#3875 (real reporter case): an assistant turn with empty visible content but
|
||||
a separate `reasoning` field must surface that reasoning as a Thinking card.
|
||||
|
||||
The per-segment inline thinkingText extraction only mines <think>/channel/turn
|
||||
tags out of `content`; it must NOT read `m.reasoning` (that constraint is
|
||||
enforced by #2565 — reasoning metadata stays low-priority Worklog detail, never
|
||||
inline-content extraction). So a run-journal-recovered anchor (empty content +
|
||||
reasoning + `_recovered_from_run_journal`) extracted no thinkingText, rendered no
|
||||
Thinking card, and collapsed to an empty hidden anchor — a session of such rows
|
||||
painted blank (only date separators). The fix, at the segment-emission point
|
||||
(AFTER the #2565-guarded inline extraction block, in LEGACY mode only), falls
|
||||
back to `_assistantReasoningPayloadText(m)` and reuses `thinkingText` ONLY when
|
||||
there is no inline thinkingText AND no visible content/files/status — keeping
|
||||
reasoning out of the forbidden extraction block while ensuring the turn is never
|
||||
blank. Legacy-only because the simplified/Worklog path already derives reasoning
|
||||
(with an exact-visible-answer echo-strip) higher up.
|
||||
"""
|
||||
body = _function_body(UI_JS, "renderMessages")
|
||||
# The fallback exists at emission time (after the #2565-guarded inline
|
||||
# extraction block), reusing thinkingText but sourced from the reasoning payload.
|
||||
assert "_assistantReasoningPayloadText(m)" in body, (
|
||||
"the empty-turn path must fall back to the message's reasoning payload"
|
||||
)
|
||||
# It is scoped to LEGACY mode (simplified path already derives reasoning with
|
||||
# echo-strip) AND empty-content turns with no inline thinkingText, so an
|
||||
# answer-bearing message's rendering is unchanged and a Worklog echo is not
|
||||
# double-rendered (Codex gate catch).
|
||||
assert "!isUser&&!m._live&&!isSimplifiedToolCalling()&&!thinkingText&&!String(content||'').trim()&&!filesHtml&&!statusHtml" in body, (
|
||||
"the reasoning fallback must be scoped to legacy-mode empty-content/no-inline-thinking turns"
|
||||
)
|
||||
|
||||
|
||||
def test_reasoning_fallback_stays_out_of_inline_extraction_block_2565():
|
||||
"""Guard against regressing #2565: the reasoning fallback must NOT live in the
|
||||
inline-content `thinkingText` extraction block (between `let thinkingText='';`
|
||||
and `const isUser=...`). That block must never reference `m.reasoning`."""
|
||||
src = UI_JS
|
||||
extraction = src.split("let thinkingText='';", 1)[1].split("const isUser=m.role==='user';", 1)[0]
|
||||
assert "m.reasoning" not in extraction
|
||||
assert "m.reasoning_content" not in extraction
|
||||
assert "_assistantReasoningPayloadText" not in extraction, (
|
||||
"the reasoning fallback must live at the segment-emission point, not inside "
|
||||
"the inline-content extraction block (#2565)"
|
||||
)
|
||||
|
||||
|
||||
def test_assistant_reasoning_payload_reads_reasoning_fields():
|
||||
"""The fallback relies on `_assistantReasoningPayloadText` reading the message's
|
||||
`reasoning` / `reasoning_content` fields (not just inline content tags)."""
|
||||
payload_fn = _function_body(UI_JS, "_assistantReasoningPayloadText")
|
||||
# Reads the direct reasoning fields off the message object.
|
||||
assert "m.reasoning_content||m.reasoning||m.thinking||m._reasoning" in payload_fn
|
||||
|
||||
|
||||
|
||||
130
tests/test_issue3875_recovery_anchor_dedup.py
Normal file
130
tests/test_issue3875_recovery_anchor_dedup.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Regression tests for #3875 — blank transcript from accumulated empty recovery anchors.
|
||||
|
||||
A session that was interrupted-and-recovered many times accumulated thousands of
|
||||
empty-content assistant rows tagged ``_recovered_from_run_journal``. Each was an
|
||||
"anchor" created by ``_append_journaled_partial_output`` / ``ensure_assistant_anchor``
|
||||
to host recovered tool cards for a tool-first stream (one that emitted tools before
|
||||
any visible text). Because a tool-first stream has no text to dedup on, the read-side
|
||||
lazy-retry path re-created a fresh empty anchor on every retry, and every distinct
|
||||
interrupted stream added its own — so the session filled with empty rows. Combined
|
||||
with the render path (which dropped empty-content reasoning-only rows), the transcript
|
||||
painted blank (only date separators).
|
||||
|
||||
This file covers the DATA side: the anchor-dedup guard so recovery reuses a single
|
||||
empty anchor per stream instead of appending an unbounded run of them. The render-side
|
||||
fix (surface the message's `reasoning` field so an empty-content turn never paints
|
||||
blank) is covered by tests/test_issue3875_blank_transcript_failsafe.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import api.profiles as profiles
|
||||
from api.models import Session, _append_journaled_partial_output
|
||||
from api.run_journal import append_run_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
# Mirror tests/test_session_lost_response_regression.py — isolate HERMES_HOME
|
||||
# so Session.save() + run-journal writes land in a throwaway sandbox.
|
||||
home = tmp_path / "hermes_home"
|
||||
home.mkdir()
|
||||
(home / "sessions").mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", home)
|
||||
return home
|
||||
|
||||
|
||||
def _tool_first_journal(session_id: str, stream_id: str) -> None:
|
||||
"""Write a run journal for a stream that emitted a tool BEFORE any text —
|
||||
the shape that forces an empty assistant anchor on recovery."""
|
||||
append_run_event(session_id, stream_id, "tool", {"name": "search_files", "preview": "q=x"})
|
||||
append_run_event(session_id, stream_id, "tool_complete", {"name": "search_files", "preview": "done"})
|
||||
|
||||
|
||||
def test_tool_first_recovery_creates_single_empty_anchor(hermes_home):
|
||||
sid = "issue3875_anchor"
|
||||
stream_id = "stream-A"
|
||||
_tool_first_journal(sid, stream_id)
|
||||
s = Session(session_id=sid, title="repro", messages=[{"role": "user", "content": "go"}])
|
||||
|
||||
# First recovery: one empty anchor is created to host the recovered tool card.
|
||||
assert _append_journaled_partial_output(s, stream_id, dedupe_existing=True) is True
|
||||
anchors = [
|
||||
m for m in s.messages
|
||||
if isinstance(m, dict)
|
||||
and m.get("_recovered_from_run_journal")
|
||||
and m.get("role") == "assistant"
|
||||
and not str(m.get("content") or "").strip()
|
||||
]
|
||||
assert len(anchors) == 1, "first recovery should create exactly one empty anchor"
|
||||
|
||||
|
||||
def test_repeated_recovery_does_not_accumulate_empty_anchors(hermes_home):
|
||||
"""The #3875 root cause: re-running recovery for the SAME stream must reuse the
|
||||
existing empty anchor, not pile up a fresh one each time."""
|
||||
sid = "issue3875_repeat"
|
||||
stream_id = "stream-B"
|
||||
_tool_first_journal(sid, stream_id)
|
||||
s = Session(session_id=sid, title="repro", messages=[{"role": "user", "content": "go"}])
|
||||
|
||||
for _ in range(20):
|
||||
_append_journaled_partial_output(s, stream_id, dedupe_existing=True)
|
||||
|
||||
anchors = [
|
||||
m for m in s.messages
|
||||
if isinstance(m, dict)
|
||||
and m.get("_recovered_from_run_journal")
|
||||
and m.get("role") == "assistant"
|
||||
and not str(m.get("content") or "").strip()
|
||||
]
|
||||
assert len(anchors) == 1, (
|
||||
f"repeated recovery for one stream must reuse a single empty anchor, "
|
||||
f"got {len(anchors)} (the unbounded-accumulation bug)"
|
||||
)
|
||||
|
||||
|
||||
def test_distinct_streams_get_distinct_anchors(hermes_home):
|
||||
"""Dedup is scoped per stream — two genuinely different interrupted streams
|
||||
each keep their own anchor (we are not collapsing unrelated turns)."""
|
||||
sid = "issue3875_multi"
|
||||
s = Session(session_id=sid, title="repro", messages=[{"role": "user", "content": "go"}])
|
||||
for stream_id in ("stream-X", "stream-Y", "stream-Z"):
|
||||
_tool_first_journal(sid, stream_id)
|
||||
# run each twice to prove per-stream reuse on top of per-stream distinctness
|
||||
_append_journaled_partial_output(s, stream_id, dedupe_existing=True)
|
||||
_append_journaled_partial_output(s, stream_id, dedupe_existing=True)
|
||||
|
||||
anchors = [
|
||||
m for m in s.messages
|
||||
if isinstance(m, dict)
|
||||
and m.get("_recovered_from_run_journal")
|
||||
and m.get("role") == "assistant"
|
||||
and not str(m.get("content") or "").strip()
|
||||
]
|
||||
stream_ids = {m.get("_recovered_stream_id") for m in anchors}
|
||||
assert len(anchors) == 3, f"expected one anchor per distinct stream, got {len(anchors)}"
|
||||
assert stream_ids == {"stream-X", "stream-Y", "stream-Z"}
|
||||
|
||||
|
||||
def test_text_bearing_recovery_still_appends_real_content(hermes_home):
|
||||
"""The dedup guard must not suppress recovery of genuine visible text — a
|
||||
stream that emitted tokens still produces a content-bearing recovered row."""
|
||||
sid = "issue3875_text"
|
||||
stream_id = "stream-T"
|
||||
append_run_event(sid, stream_id, "token", {"text": "Hello "})
|
||||
append_run_event(sid, stream_id, "token", {"text": "world"})
|
||||
append_run_event(sid, stream_id, "done", {})
|
||||
s = Session(session_id=sid, title="repro", messages=[{"role": "user", "content": "go"}])
|
||||
|
||||
assert _append_journaled_partial_output(s, stream_id, dedupe_existing=True) is True
|
||||
recovered_text = [
|
||||
m for m in s.messages
|
||||
if isinstance(m, dict)
|
||||
and m.get("_recovered_from_run_journal")
|
||||
and str(m.get("content") or "").strip()
|
||||
]
|
||||
assert any("Hello world" in m["content"] for m in recovered_text), (
|
||||
"token-bearing recovery must still append the visible assistant text"
|
||||
)
|
||||
117
tests/test_issue3877_midstream_flicker.py
Normal file
117
tests/test_issue3877_midstream_flicker.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Regression coverage for #3877 — mid-stream transcript flicker (content disappears/reappears).
|
||||
|
||||
#3877: in long sessions, during streaming, the latest assistant reply content flickers —
|
||||
disappears and then reappears moments later.
|
||||
|
||||
Root cause: the live streaming text is written by the ``smd`` parser into a DOM node
|
||||
*inside* ``#liveAssistantTurn``. When ``renderMessages()`` is reached mid-stream — e.g.
|
||||
the clarify-response echo (``messages.js``) or a CLI-import refresh push a render while a
|
||||
stream is live — its unconditional ``inner.innerHTML=''`` rebuild DETACHES that node. The
|
||||
parser keeps writing into the now-orphaned element, so the visible content vanishes until
|
||||
the next stream event rebuilds ``#liveAssistantTurn`` from scratch (the "disappears, then
|
||||
reappears" frame).
|
||||
|
||||
Fix: before the ``inner.innerHTML=''`` rebuild, capture the live ``#liveAssistantTurn``
|
||||
DOM node (the actual node — the parser holds a live reference into it, so serialising to
|
||||
HTML would not help). After the rebuild, if the freshly-rebuilt live turn has LESS
|
||||
streamed text than the preserved node (because the live assistant message's content is
|
||||
still empty in ``S.messages`` until the stream settles), swap the preserved node back in
|
||||
via ``_mergeRestoredLiveAssistantSegment`` so the parser target stays connected and the
|
||||
visible text never blanks. Only ever runs for the streaming session's own live turn
|
||||
(``INFLIGHT[sid]`` gate); a settled transcript with no live turn is untouched.
|
||||
|
||||
Verified RED→GREEN in an isolated browser against the real shipped ``renderMessages``:
|
||||
- RED (master): after a mid-stream ``renderMessages({preserveScroll:true})``, the parser
|
||||
node is detached (``isConnected === false``), the rebuilt live turn shows only
|
||||
"Running" with no streamed text, and further tokens write into an orphaned node.
|
||||
- GREEN (fix): the parser node stays connected, the same live node is preserved across
|
||||
the rebuild, the streamed text remains visible, and tokens written after the rebuild
|
||||
still land in the visible node. A settled (non-streaming) session renders normally
|
||||
(2 assistant turns + 2 user rows, both answers visible) — the fix is a no-op there.
|
||||
|
||||
These are static source-structure assertions over the shipped ``renderMessages`` so the
|
||||
invariant cannot silently regress.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
start = src.find(marker)
|
||||
assert start != -1, f"{name} not found"
|
||||
brace = src.find("{", start)
|
||||
assert brace != -1, f"{name} body not found"
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
ch = src[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[brace + 1 : idx]
|
||||
raise AssertionError(f"{name} body not closed")
|
||||
|
||||
|
||||
def test_render_messages_captures_live_turn_before_rebuild():
|
||||
"""#3877: renderMessages must capture the live turn node before innerHTML=''."""
|
||||
body = _function_body(UI_JS, "renderMessages")
|
||||
# The capture is anchored by its issue tag so it is greppable + intentional.
|
||||
assert "Mid-stream flicker fix (#3877)" in body, (
|
||||
"the #3877 live-turn preservation is missing from renderMessages"
|
||||
)
|
||||
# The capture must happen BEFORE the destructive rebuild. renderMessages has
|
||||
# several `inner.innerHTML=''` sites; the relevant one is the rebuild that
|
||||
# immediately follows the capture, so assert on the FIRST rebuild at/after the
|
||||
# capture index.
|
||||
capture_idx = body.find("_preservedLiveTurn=null")
|
||||
assert capture_idx != -1, "_preservedLiveTurn capture not found"
|
||||
rebuild_idx = body.find("inner.innerHTML=''", capture_idx)
|
||||
assert rebuild_idx != -1, "inner.innerHTML='' rebuild after capture not found"
|
||||
assert capture_idx < rebuild_idx, (
|
||||
"the live-turn capture must run BEFORE inner.innerHTML='' or the node is "
|
||||
"already detached when captured"
|
||||
)
|
||||
|
||||
|
||||
def test_capture_is_gated_on_streaming_session():
|
||||
"""The capture only runs for the streaming session's own live turn — never for a
|
||||
settled transcript (INFLIGHT[sid] gate + session-id match)."""
|
||||
body = _function_body(UI_JS, "renderMessages")
|
||||
failsafe = body[body.find("Mid-stream flicker fix (#3877)") :]
|
||||
# Gated on an in-flight stream for this session.
|
||||
assert "INFLIGHT[sid]" in failsafe
|
||||
# The captured turn must belong to the current session (no cross-session revive).
|
||||
assert "liveAssistantTurn" in failsafe
|
||||
assert "dataset.sessionId" in failsafe
|
||||
|
||||
|
||||
def test_reattach_keeps_longer_live_segment_via_length_gated_swap():
|
||||
"""After the rebuild, the preserved node is swapped back only when it carries MORE
|
||||
streamed text than the rebuilt live turn. The length guard establishes the preserved
|
||||
(parser) node strictly wins, so a plain replaceWith is sufficient — no segment merge
|
||||
is needed (a merge would be a no-op under this guard), and the in-progress reply is
|
||||
never blanked."""
|
||||
body = _function_body(UI_JS, "renderMessages")
|
||||
reattach = body[body.find("Re-attach the preserved live turn (#3877)") :]
|
||||
assert reattach, "the #3877 re-attach block is missing"
|
||||
# Length comparison gates the swap (only restore when preserved has more text).
|
||||
assert "_liveAssistantSegmentTextLength" in reattach
|
||||
assert "_rebuiltLen<_preservedLen" in reattach
|
||||
# The swap replaces the rebuilt node with the preserved (parser-referenced) node.
|
||||
assert "replaceWith(_preservedLiveTurn)" in reattach
|
||||
|
||||
|
||||
def test_reattach_runs_after_rebuild_loop():
|
||||
"""The re-attach must run AFTER the rebuild (so a freshly-rebuilt live turn exists to
|
||||
compare against / replace) but is still inside renderMessages."""
|
||||
body = _function_body(UI_JS, "renderMessages")
|
||||
capture_idx = body.find("_preservedLiveTurn=null")
|
||||
reattach_idx = body.find("Re-attach the preserved live turn (#3877)")
|
||||
assert capture_idx != -1 and reattach_idx != -1
|
||||
assert reattach_idx > capture_idx, "re-attach must come after the capture/rebuild"
|
||||
@@ -29,14 +29,15 @@ def test_done_path_marks_active_session_as_viewed():
|
||||
def test_cancel_path_marks_active_session_as_viewed():
|
||||
cancel_idx = MESSAGES_JS.find("source.addEventListener('cancel'")
|
||||
assert cancel_idx != -1, "cancel handler not found in messages.js"
|
||||
cancel_block = MESSAGES_JS[cancel_idx:MESSAGES_JS.find("async function _restoreSettledSession(source)", cancel_idx)]
|
||||
restore_marker = "async function _restoreSettledSession(source"
|
||||
cancel_block = MESSAGES_JS[cancel_idx:MESSAGES_JS.find(restore_marker, cancel_idx)]
|
||||
assert "_markSessionViewed(activeSid" in cancel_block, (
|
||||
"cancel handler must mark the active session as viewed after settling messages"
|
||||
)
|
||||
|
||||
|
||||
def test_restore_and_error_paths_mark_active_session_as_viewed():
|
||||
restore_idx = MESSAGES_JS.find("async function _restoreSettledSession(source)")
|
||||
restore_idx = MESSAGES_JS.find("async function _restoreSettledSession(source")
|
||||
assert restore_idx != -1, "_restoreSettledSession(source) not found in messages.js"
|
||||
restore_block = MESSAGES_JS[restore_idx:MESSAGES_JS.find("function _handleStreamError(source)", restore_idx)]
|
||||
assert "const completedSid=session.session_id||activeSid;" in restore_block
|
||||
|
||||
@@ -363,7 +363,7 @@ def test_switching_away_counts_as_background_completion():
|
||||
|
||||
|
||||
def test_restore_settled_background_stream_marks_completion_unread():
|
||||
restore_idx = MESSAGES_JS.find("async function _restoreSettledSession(source)")
|
||||
restore_idx = MESSAGES_JS.find("async function _restoreSettledSession(source")
|
||||
assert restore_idx != -1, "_restoreSettledSession(source) not found"
|
||||
restore_block = MESSAGES_JS[restore_idx:MESSAGES_JS.find("function _handleStreamError", restore_idx)]
|
||||
|
||||
|
||||
@@ -290,6 +290,44 @@ class TestCommonLLMShapes:
|
||||
assert "\n " not in out.replace("</blockquote>", "")
|
||||
|
||||
|
||||
class TestMarkdownListsWithLatex:
|
||||
"""Drive the real renderer through the list path that shares the KaTeX placeholders."""
|
||||
|
||||
def test_plain_lists_still_render_markers(self, driver_path):
|
||||
out = _render(driver_path, "- one\n- two\n\n1. alpha\n2. beta")
|
||||
assert "<ul><li>one</li><li>two</li></ul>" in out
|
||||
assert '<ol><li value="1">alpha</li><li value="2">beta</li></ol>' in out
|
||||
|
||||
def test_continuation_line_stays_inside_same_list_item(self, driver_path):
|
||||
out = _render(driver_path, "- first line\n second line\n- next item")
|
||||
assert "<ul>" in out
|
||||
assert "<li>first line\nsecond line</li>" in out, out
|
||||
assert "<li>next item</li>" in out
|
||||
|
||||
def test_nested_indentation_stays_in_list(self, driver_path):
|
||||
out = _render(driver_path, "- parent\n - child")
|
||||
assert "<ul>" in out
|
||||
assert "<li>parent</li>" in out
|
||||
assert '<li style="margin-left:16px">child</li>' in out
|
||||
|
||||
def test_display_math_line_stays_inside_list_item(self, driver_path):
|
||||
src = "- intro\n\n $$x^2$$\n\n continuation"
|
||||
out = _render(driver_path, src)
|
||||
assert "<ul>" in out and "</ul>" in out
|
||||
assert "<p>continuation</p>" not in out, out
|
||||
assert "<div class=\"katex-block\" data-katex=\"display\">x^2</div>" in out
|
||||
assert "<li>intro\n<div class=\"katex-block\" data-katex=\"display\">x^2</div>\ncontinuation</li>" in out, out
|
||||
|
||||
def test_mixed_markdown_and_latex_ordered_list_preserves_all_items(self, driver_path):
|
||||
src = "1. **First** with $x$\n2. $$y$$\n3. tail"
|
||||
out = _render(driver_path, src)
|
||||
assert "<ol>" in out and "</ol>" in out
|
||||
assert "<strong>First</strong>" in out
|
||||
assert "<span class=\"katex-inline\" data-katex=\"inline\">x</span>" in out
|
||||
assert "<div class=\"katex-block\" data-katex=\"display\">y</div>" in out
|
||||
assert '<li value="3">tail</li>' in out
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Block-level constructs INSIDE blockquotes — the six bugs documented in
|
||||
# blockquote-rendering-bugs.md. Each test feeds the exact input from the
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Regression tests for the Simplified tool calling setting."""
|
||||
"""Regression tests for the deprecated Simplified tool calling setting."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
|
||||
def test_simplified_tool_calling_defaults_enabled_and_round_trips(monkeypatch, tmp_path):
|
||||
def test_simplified_tool_calling_defaults_enabled_but_legacy_false_is_ignored(monkeypatch, tmp_path):
|
||||
import api.config as config
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
@@ -14,16 +13,21 @@ def test_simplified_tool_calling_defaults_enabled_and_round_trips(monkeypatch, t
|
||||
assert loaded["simplified_tool_calling"] is True
|
||||
|
||||
saved = config.save_settings({"simplified_tool_calling": False})
|
||||
assert saved["simplified_tool_calling"] is False
|
||||
assert json.loads(settings_path.read_text(encoding="utf-8"))["simplified_tool_calling"] is False
|
||||
|
||||
saved = config.save_settings({"simplified_tool_calling": True})
|
||||
assert saved["simplified_tool_calling"] is True
|
||||
assert json.loads(settings_path.read_text(encoding="utf-8"))["simplified_tool_calling"] is True
|
||||
|
||||
settings_path.write_text(
|
||||
json.dumps({"simplified_tool_calling": False}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loaded = config.load_settings()
|
||||
assert loaded["simplified_tool_calling"] is True
|
||||
|
||||
|
||||
def test_simplified_tool_calling_is_a_valid_boolean_setting():
|
||||
def test_simplified_tool_calling_is_legacy_compatibility_not_user_setting():
|
||||
import api.config as config
|
||||
|
||||
assert "simplified_tool_calling" in config._SETTINGS_DEFAULTS
|
||||
assert "simplified_tool_calling" in config._SETTINGS_BOOL_KEYS
|
||||
assert "simplified_tool_calling" in config._SETTINGS_ALLOWED_KEYS
|
||||
assert "simplified_tool_calling" in config._SETTINGS_LEGACY_DROP_KEYS
|
||||
assert "simplified_tool_calling" not in config._SETTINGS_BOOL_KEYS
|
||||
assert "simplified_tool_calling" not in config._SETTINGS_ALLOWED_KEYS
|
||||
|
||||
121
tests/test_stream_end_recovery_gating.py
Normal file
121
tests/test_stream_end_recovery_gating.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Regression coverage for stream-end recovery ordering.
|
||||
|
||||
#3877-style recovery relies on one subtle path:
|
||||
when `stream_end` arrives while the active live assistant row is still
|
||||
present, cleanup should be deferred briefly to allow pending final SSE updates to
|
||||
settle, then performed through the shared terminal recovery helper.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _event_block(event_name: str) -> str:
|
||||
marker = f"source.addEventListener('{event_name}'"
|
||||
start = MESSAGES_JS.find(marker)
|
||||
assert start >= 0, f"missing {event_name} listener"
|
||||
brace = MESSAGES_JS.find("{", start)
|
||||
assert brace >= 0, f"missing {event_name} listener body"
|
||||
depth = 0
|
||||
i = brace
|
||||
while i < len(MESSAGES_JS):
|
||||
if MESSAGES_JS[i] == "{":
|
||||
depth += 1
|
||||
elif MESSAGES_JS[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return MESSAGES_JS[brace : i + 1]
|
||||
i += 1
|
||||
raise AssertionError(f"unclosed {event_name} listener body")
|
||||
|
||||
|
||||
def _function_body(name: str) -> str:
|
||||
marker = f"async function {name}("
|
||||
start = MESSAGES_JS.find(marker)
|
||||
if start < 0:
|
||||
marker = f"function {name}("
|
||||
start = MESSAGES_JS.find(marker)
|
||||
assert start >= 0, f"missing function: {name}"
|
||||
brace = MESSAGES_JS.find("{", start)
|
||||
assert brace >= 0, f"missing {name} body"
|
||||
depth = 0
|
||||
i = brace
|
||||
while i < len(MESSAGES_JS):
|
||||
if MESSAGES_JS[i] == "{":
|
||||
depth += 1
|
||||
elif MESSAGES_JS[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return MESSAGES_JS[brace : i + 1]
|
||||
i += 1
|
||||
raise AssertionError(f"unclosed function body: {name}")
|
||||
|
||||
|
||||
def test_stream_end_defers_settlement_when_live_assistant_still_present():
|
||||
body = _event_block("stream_end")
|
||||
assert "if(S.activeStreamId===streamId && _liveStreamEndScenePresent())" in body, (
|
||||
"stream_end should defer terminal cleanup while active live scene content is still present"
|
||||
)
|
||||
assert "_scheduleStreamEndRecovery(source);" in body, (
|
||||
"stream_end should schedule the deferred recovery timer before returning"
|
||||
)
|
||||
assert "_scheduleStreamEndRecovery(source)" in body, (
|
||||
"stream_end must delegate deferred cleanup to helper"
|
||||
)
|
||||
|
||||
|
||||
def test_stream_end_fallback_does_not_finalize_when_session_is_still_active():
|
||||
body = _event_block("stream_end")
|
||||
assert "const status=await _restoreSettledSession(source,{status:true});" in body
|
||||
assert "if(status==='active'&&S.activeStreamId===streamId)" in body
|
||||
assert "_scheduleStreamEndRecovery(source,200);" in body
|
||||
assert "_finalizeStreamEndFallback(source);" in body
|
||||
|
||||
|
||||
def test_stream_end_recovery_helper_retries_while_session_is_still_active():
|
||||
fn = _function_body("_runStreamEndRecovery")
|
||||
assert "if(_streamFinalized || _terminalStateReached || !_pendingStreamEndRecovery)" in fn
|
||||
assert "_restoreSettledSession(source,{status:true})" in fn
|
||||
assert "if(status==='active'&&_streamEndRecoveryAttempts<10)" in fn
|
||||
assert "_scheduleStreamEndRecovery(source,200);" in fn
|
||||
assert "_finalizeStreamEndFallback(source);" in fn
|
||||
|
||||
|
||||
def test_stream_end_fallback_helper_clears_owner_state_before_closing():
|
||||
fn = _function_body("_finalizeStreamEndFallback")
|
||||
assert "_terminalStateReached=true;" in fn
|
||||
assert "_streamFinalized=true;" in fn
|
||||
assert "_clearOwnerInflightState();" in fn
|
||||
assert "_clearApprovalForOwner();" in fn
|
||||
assert "_clearClarifyForOwner('terminal');" in fn
|
||||
assert "renderMessages({preserveScroll:true});" in fn
|
||||
assert "_setActivePaneIdleIfOwner();" in fn
|
||||
assert "_closeSource(source)" in fn
|
||||
|
||||
|
||||
def test_stream_end_live_scene_detection_includes_empty_text_activity():
|
||||
fn = _function_body("_liveStreamEndScenePresent")
|
||||
assert "if(assistantText||assistantRow) return true;" in fn
|
||||
assert "liveReasoningText||reasoningText" in fn
|
||||
assert "inflight.toolCalls.length" in fn
|
||||
assert "data-live-worklog-shell" in fn
|
||||
assert "data-thinking-active" in fn
|
||||
|
||||
|
||||
def test_restore_settled_session_can_report_active_pending_status():
|
||||
fn = _function_body("_restoreSettledSession")
|
||||
assert "async function _restoreSettledSession(source, options=null)" in MESSAGES_JS
|
||||
assert "arguments[1]" not in fn
|
||||
assert "const returnStatus=!!(options&&options.status);" in fn
|
||||
assert "return returnStatus?'active':false;" in fn
|
||||
assert "return returnStatus?'restored':true;" in fn
|
||||
|
||||
|
||||
def test_stream_end_recovery_state_is_cleared_on_done_and_terminal_events():
|
||||
assert "_clearStreamEndRecovery();" in _event_block("done")
|
||||
assert "_clearStreamEndRecovery();" in _event_block("stream_end")
|
||||
assert "_clearStreamEndRecovery();" in _event_block("cancel")
|
||||
assert "_clearStreamEndRecovery();" in _event_block("apperror")
|
||||
@@ -153,29 +153,24 @@ def _run_thinking_echo_helper(*args: str) -> str:
|
||||
|
||||
|
||||
class TestToolCallGroupingStatic:
|
||||
def test_simplified_tool_calling_setting_is_wired_through_frontend(self):
|
||||
assert "settingsSimplifiedToolCalling" in (REPO / "static" / "index.html").read_text(encoding="utf-8"), (
|
||||
"Settings should expose a Compact tool activity checkbox."
|
||||
)
|
||||
assert "window._simplifiedToolCalling" in (REPO / "static" / "boot.js").read_text(encoding="utf-8"), (
|
||||
"Boot should hydrate simplified_tool_calling into a runtime flag."
|
||||
def test_simplified_tool_calling_setting_is_hidden_from_frontend(self):
|
||||
assert "settingsSimplifiedToolCalling" not in (REPO / "static" / "index.html").read_text(encoding="utf-8"), (
|
||||
"Settings should no longer expose the deprecated Compact tool activity checkbox."
|
||||
)
|
||||
panels = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
assert "settingsSimplifiedToolCalling" in panels and "simplified_tool_calling" in panels, (
|
||||
"Settings panel should load and save the simplified_tool_calling setting."
|
||||
assert "settingsSimplifiedToolCalling" not in panels, (
|
||||
"Settings panel should not load or save the deprecated simplified_tool_calling setting."
|
||||
)
|
||||
|
||||
def test_simplified_tool_calling_autosave_hot_applies_renderer_mode(self):
|
||||
def test_simplified_tool_calling_renderer_is_forced_to_worklog_mode(self):
|
||||
boot = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
assert "window._simplifiedToolCalling=true" in boot, (
|
||||
"Boot should keep the Compact Worklog renderer enabled regardless of legacy saved values."
|
||||
)
|
||||
panels = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
fn = _function_body(panels, "_autosavePreferencesSettings")
|
||||
assert "window._simplifiedToolCalling" in fn, (
|
||||
"Autosaving Compact tool activity should update the live renderer flag immediately."
|
||||
)
|
||||
assert "clearMessageRenderCache()" in fn, (
|
||||
"Autosaving Compact tool activity should invalidate cached transcript HTML."
|
||||
)
|
||||
assert "renderMessages()" in fn, (
|
||||
"Autosaving Compact tool activity should rebuild the visible transcript without a refresh."
|
||||
assert "simplified_tool_calling" not in fn and "window._simplifiedToolCalling" not in fn, (
|
||||
"Preferences autosave should no longer hot-apply the deprecated renderer switch."
|
||||
)
|
||||
|
||||
def test_render_messages_gates_settled_activity_grouping(self):
|
||||
|
||||
Reference in New Issue
Block a user