From 93b7d35bfaa7df7773f1ea82697ac007993f0eab Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 09:04:00 +0800 Subject: [PATCH 01/15] Issue #2057 Slice 2: Add worktree remove action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - POST /api/session/worktree/remove — removes a session's git worktree - Guards: stream/terminal lock, dirty/untracked without force - remove_worktree_for_session() in api/worktrees.py Frontend: - 'Remove Worktree' context menu item + confirm modal - i18n keys for all 11 locales Tests: - 5 tests: clean remove, missing worktree, no-path, route success, 404 --- ARCHITECTURE.md | 5 +- api/routes.py | 53 +++++++++++ api/worktrees.py | 87 ++++++++++++++++++ static/i18n.js | 143 +++++++++++++++++++++++++++++ static/sessions.js | 78 ++++++++++++++++ tests/test_worktree_remove.py | 165 ++++++++++++++++++++++++++++++++++ 6 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 tests/test_worktree_remove.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bc8b3630..1d8f5a71 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -156,10 +156,11 @@ Python stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in thread. The Handler class subclasses BaseHTTPRequestHandler with two methods: do_GET Routes: /, /health, /api/session, /api/sessions, /api/list, - /api/chat/stream, /api/file, /api/approval/pending + /api/chat/stream, /api/file, /api/approval/pending, + /api/session/worktree/status do_POST Routes: /api/upload, /api/session/new, /api/session/update, /api/session/delete, /api/chat/start, /api/chat, - /api/approval/respond + /api/approval/respond, /api/session/worktree/remove Routing is a flat if/elif chain inside each method. No routing framework. diff --git a/api/routes.py b/api/routes.py index 65f12285..a78cead0 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3066,6 +3066,7 @@ def handle_get(handler, parsed) -> bool: if parsed.path.startswith("/static/"): return _serve_static(handler, parsed) + if parsed.path == "/api/session/worktree/status": query = parse_qs(parsed.query) sid = query.get("session_id", [""])[0] @@ -4273,6 +4274,58 @@ def handle_post(handler, parsed) -> bool: set_last_workspace(new_ws) return j(handler, {"session": s.compact() | {"messages": s.messages}}) + + + if parsed.path == "/api/session/worktree/status": + query = parse_qs(parsed.query) + sid = query.get("session_id", [""])[0] + if not sid: + return bad(handler, "session_id is required", status=400) + try: + s = get_session(sid, metadata_only=True) + except KeyError: + return bad(handler, "Session not found", status=404) + try: + from api.worktrees import worktree_status_for_session + + return j(handler, {"status": worktree_status_for_session(s)}) + except ValueError as exc: + return bad(handler, str(exc), status=400) + except Exception as exc: + logger.exception("failed to read worktree status for session %s", sid) + return bad(handler, _sanitize_error(exc), status=500) + + if parsed.path == "/api/session/compress/status": + query = parse_qs(parsed.query) + return _handle_session_compress_status(handler, query.get("session_id", [""])[0]) + + if parsed.path == "/api/session": + import time as _time + _t0 = _time.monotonic() + _debug_slow = os.environ.get("HERMES_DEBUG_SLOW", "") + if parsed.path == "/api/session/worktree/remove": + sid = body.get("session_id", "") + if not sid or not isinstance(sid, str) or not sid.strip(): + return bad(handler, "session_id must be a non-empty string", status=400) + sid = sid.strip() + if not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid): + return bad(handler, "Invalid session_id", 400) + try: + s = get_session(sid, metadata_only=True) + except KeyError: + return bad(handler, "Session not found", status=404) + force = bool(body.get("force", False)) + try: + from api.worktrees import remove_worktree_for_session + + result = remove_worktree_for_session(s, force=force) + return j(handler, result) + except ValueError as exc: + return bad(handler, str(exc), status=400) + except Exception as exc: + logger.exception("failed to remove worktree for session %s", sid) + return bad(handler, _sanitize_error(exc), status=500) + if parsed.path == "/api/session/delete": sid = body.get("session_id", "") if not sid: diff --git a/api/worktrees.py b/api/worktrees.py index e71fea6c..64929a1f 100644 --- a/api/worktrees.py +++ b/api/worktrees.py @@ -201,6 +201,93 @@ def worktree_status_for_session(session) -> dict: return status +def remove_worktree_for_session(session, *, force: bool = False) -> dict: + """Remove a session's git worktree from disk. + + Returns status dict with keys: ok, removed_path, warnings. + Raises ValueError for terminal blockers (locked by stream/terminal, + dirty with force=False). + """ + raw_path = getattr(session, "worktree_path", None) + if not raw_path: + raise ValueError("Session is not worktree-backed") + + worktree_path = _resolve_path(raw_path) + if worktree_path is None: + raise ValueError("Session is not worktree-backed") + + # Read current status before removal + status = worktree_status_for_session(session) + + if not status["exists"]: + return { + "ok": True, + "removed_path": str(worktree_path), + "warnings": ["Worktree directory no longer exists on disk."], + } + + warnings = [] + + # Guard: locked by stream + if status["locked_by_stream"]: + raise ValueError("Worktree is locked by an active streaming session") + + # Guard: locked by terminal + if status["locked_by_terminal"]: + raise ValueError("Worktree is locked by an active terminal session") + + # Guard: dirty / untracked files without force + if status["dirty"] and not force: + raise ValueError( + "Worktree has uncommitted changes. Use force=true to override." + ) + if status["untracked_count"] > 0: + if force: + warnings.append( + f"{status['untracked_count']} untracked file(s) will be removed." + ) + else: + raise ValueError( + f"Worktree has {status['untracked_count']} untracked file(s). " + "Use force=true to override." + ) + + # Remove the worktree — must run from the repo root, not the worktree dir + repo_root = getattr(session, "worktree_repo_root", None) + if not repo_root: + raise ValueError("Session missing worktree_repo_root") + try: + result = _run_git( + ["worktree", "remove", "--force", str(worktree_path)], + str(repo_root), + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ValueError(f"Failed to remove worktree: {exc}") from exc + + if result.returncode != 0: + stderr = (result.stderr or "").strip().split("\n")[-1] + raise ValueError( + f"git worktree remove failed: {stderr or result.stdout.strip()}" + ) + + # Prune in case the worktree dir was already gone + try: + _run_git( + ["worktree", "prune"], + str(repo_root), + timeout=5, + ) + except Exception: + pass + + return { + "ok": True, + "removed_path": str(worktree_path), + "warnings": warnings or None, + } + + def find_git_repo_root(workspace: str | Path) -> Path: """Return the enclosing git repo root for *workspace*. diff --git a/static/i18n.js b/static/i18n.js index 4cdd841c..235c5d5e 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -430,6 +430,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`, session_deleted: 'Conversation deleted', session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.', + session_worktree_remove: 'Remove worktree', + session_worktree_remove_desc: (path) => `Delete the git worktree at ${path} from disk`, + session_worktree_remove_confirm: (path) => `Remove git worktree from disk?\n\nPath: ${path}\n\nThis will delete the entire worktree directory. Session data remains in WebUI.`, + session_worktree_remove_not_exists: (path) => `The worktree at ${path} no longer exists on disk.`, + session_worktree_remove_confirm_label: 'Remove', + session_worktree_removed: 'Worktree removed.', + session_worktree_remove_failed: 'Failed to remove worktree: ', + session_worktree_remove_status_failed: 'Failed to read worktree status: ', + session_worktree_remove_locked_by_stream: 'Cannot remove — an active streaming session is using this worktree.', + session_worktree_remove_locked_by_terminal: 'Cannot remove — an active terminal session is using this worktree.', + session_worktree_remove_dirty_warning: 'WARNING: This worktree has uncommitted changes which will be lost.', + session_worktree_remove_untracked_warning: (count) => `${count} untracked file(s) will be permanently deleted.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} unpushed commit(s) will be lost.`, session_select_mode: 'Select', session_select_mode_desc: 'Select conversations to batch manage', session_select_all: 'Select all', @@ -1538,6 +1551,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `Eliminare questa conversazione? Il worktree in ${path} rimarrà su disco.`, session_deleted: 'Conversazione eliminata', session_deleted_worktree: 'Conversazione eliminata. Il worktree rimane su disco.', + session_worktree_remove: 'Rimuovi worktree', + session_worktree_remove_desc: (path) => `Elimina il git worktree in ${path} dal disco`, + session_worktree_remove_confirm: (path) => `Rimuovere il git worktree dal disco?\n\nPercorso: ${path}\n\nVerrà eliminata l'intera directory del worktree. I dati della sessione restano in WebUI.`, + session_worktree_remove_not_exists: (path) => `Il worktree in ${path} non esiste più sul disco.`, + session_worktree_remove_confirm_label: 'Rimuovi', + session_worktree_removed: 'Worktree rimosso.', + session_worktree_remove_failed: 'Rimozione worktree fallita: ', + session_worktree_remove_status_failed: 'Lettura stato worktree fallita: ', + session_worktree_remove_locked_by_stream: 'Impossibile rimuovere — una sessione di streaming attiva sta usando questo worktree.', + session_worktree_remove_locked_by_terminal: 'Impossibile rimuovere — una sessione terminale attiva sta usando questo worktree.', + session_worktree_remove_dirty_warning: 'ATTENZIONE: Questo worktree ha modifiche non committate che andranno perse.', + session_worktree_remove_untracked_warning: (count) => `${count} file non tracciati verranno eliminati definitivamente.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit non inviati andranno persi.`, session_select_mode: 'Seleziona', session_select_mode_desc: 'Seleziona conversazioni per gestione in blocco', session_select_all: 'Seleziona tutto', @@ -2641,6 +2667,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `この会話を削除しますか? ${path} の worktree はディスク上に残ります。`, session_deleted: '会話を削除しました', session_deleted_worktree: '会話を削除しました。Worktree はディスク上に残ります。', + session_worktree_remove: 'ワークツリーを削除', + session_worktree_remove_desc: (path) => `${path} のgitワークツリーをディスクから削除します`, + session_worktree_remove_confirm: (path) => `gitワークツリーをディスクから削除しますか?\n\nパス: ${path}\n\nワークツリーディレクトリ全体が削除されます。セッションデータはWebUIに残ります。`, + session_worktree_remove_not_exists: (path) => `${path} のワークツリーはディスク上に存在しません。`, + session_worktree_remove_confirm_label: '削除', + session_worktree_removed: 'ワークツリーを削除しました。', + session_worktree_remove_failed: 'ワークツリーの削除に失敗: ', + session_worktree_remove_status_failed: 'ワークツリー状態の読み取りに失敗: ', + session_worktree_remove_locked_by_stream: '削除できません — アクティブなストリーミングセッションがこのワークツリーを使用中です。', + session_worktree_remove_locked_by_terminal: '削除できません — アクティブな端末セッションがこのワークツリーを使用中です。', + session_worktree_remove_dirty_warning: '警告: このワークツリーにはコミットされていない変更があり、失われます。', + session_worktree_remove_untracked_warning: (count) => `${count}件の追跡されていないファイルが完全に削除されます。`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead}件の未プッシュコミットが失われます。`, session_select_mode: '選択', session_select_mode_desc: '会話を選択して一括管理', session_select_all: 'すべて選択', @@ -4187,6 +4226,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`, session_deleted: 'Conversation deleted', session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.', + session_worktree_remove: 'Remove worktree', + session_worktree_remove_desc: (path) => `Delete the git worktree at ${path} from disk`, + session_worktree_remove_confirm: (path) => `Remove git worktree from disk?\n\nPath: ${path}\n\nThis will delete the entire worktree directory. Session data remains in WebUI.`, + session_worktree_remove_not_exists: (path) => `The worktree at ${path} no longer exists on disk.`, + session_worktree_remove_confirm_label: 'Remove', + session_worktree_removed: 'Worktree removed.', + session_worktree_remove_failed: 'Failed to remove worktree: ', + session_worktree_remove_status_failed: 'Failed to read worktree status: ', + session_worktree_remove_locked_by_stream: 'Cannot remove — an active streaming session is using this worktree.', + session_worktree_remove_locked_by_terminal: 'Cannot remove — an active terminal session is using this worktree.', + session_worktree_remove_dirty_warning: 'WARNING: This worktree has uncommitted changes which will be lost.', + session_worktree_remove_untracked_warning: (count) => `${count} untracked file(s) will be permanently deleted.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} unpushed commit(s) will be lost.`, session_duplicate: 'Duplicate conversation', session_duplicate_desc: 'Create a copy with the same workspace and model', session_duplicate_failed: 'Duplicate failed: ', @@ -5217,6 +5269,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `¿Eliminar esta conversación? El worktree en ${path} permanecerá en disco.`, session_deleted: 'Conversación eliminada', session_deleted_worktree: 'Conversación eliminada. El worktree permanece en disco.', + session_worktree_remove: 'Eliminar worktree', + session_worktree_remove_desc: (path) => `Eliminar el git worktree en ${path} del disco`, + session_worktree_remove_confirm: (path) => `¿Eliminar el git worktree del disco?\n\nRuta: ${path}\n\nSe eliminará todo el directorio del worktree. Los datos de la sesión permanecen en WebUI.`, + session_worktree_remove_not_exists: (path) => `El worktree en ${path} ya no existe en el disco.`, + session_worktree_remove_confirm_label: 'Eliminar', + session_worktree_removed: 'Worktree eliminado.', + session_worktree_remove_failed: 'Error al eliminar worktree: ', + session_worktree_remove_status_failed: 'Error al leer el estado del worktree: ', + session_worktree_remove_locked_by_stream: 'No se puede eliminar — una sesión de streaming activa está usando este worktree.', + session_worktree_remove_locked_by_terminal: 'No se puede eliminar — una sesión de terminal activa está usando este worktree.', + session_worktree_remove_dirty_warning: 'ADVERTENCIA: Este worktree tiene cambios no confirmados que se perderán.', + session_worktree_remove_untracked_warning: (count) => `${count} archivo(s) no rastreados se eliminarán permanentemente.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) no enviados se perderán.`, session_duplicate: 'Duplicate conversation', session_duplicate_desc: 'Create a copy with the same workspace and model', session_duplicate_failed: 'Duplicate failed: ', @@ -5987,6 +6052,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `Diese Konversation löschen? Der Worktree unter ${path} bleibt auf der Festplatte.`, session_deleted: 'Konversation gelöscht', session_deleted_worktree: 'Konversation gelöscht. Der Worktree bleibt auf der Festplatte.', + session_worktree_remove: 'Worktree entfernen', + session_worktree_remove_desc: (path) => `Git-Worktree unter ${path} von der Festplatte löschen`, + session_worktree_remove_confirm: (path) => `Git-Worktree von der Festplatte entfernen?\n\nPfad: ${path}\n\nDas gesamte Worktree-Verzeichnis wird gelöscht. Sitzungsdaten bleiben in WebUI.`, + session_worktree_remove_not_exists: (path) => `Der Worktree unter ${path} existiert nicht mehr auf der Festplatte.`, + session_worktree_remove_confirm_label: 'Entfernen', + session_worktree_removed: 'Worktree entfernt.', + session_worktree_remove_failed: 'Fehler beim Entfernen des Worktree: ', + session_worktree_remove_status_failed: 'Fehler beim Lesen des Worktree-Status: ', + session_worktree_remove_locked_by_stream: 'Entfernen nicht möglich — eine aktive Streaming-Sitzung verwendet diesen Worktree.', + session_worktree_remove_locked_by_terminal: 'Entfernen nicht möglich — eine aktive Terminal-Sitzung verwendet diesen Worktree.', + session_worktree_remove_dirty_warning: 'WARNUNG: Dieser Worktree hat nicht festgeschriebene Änderungen, die verloren gehen.', + session_worktree_remove_untracked_warning: (count) => `${count} nicht verfolgte Datei(en) werden dauerhaft gelöscht.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} nicht gepushte Commit(s) gehen verloren.`, session_duplicate: 'Duplicate conversation', session_duplicate_desc: 'Create a copy with the same workspace and model', session_duplicate_failed: 'Duplicate failed: ', @@ -7298,6 +7376,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `删除此会话?位于 ${path} 的 worktree 将保留在磁盘上。`, session_deleted: '会话已删除', session_deleted_worktree: '会话已删除。Worktree 仍保留在磁盘上。', + session_worktree_remove: '删除 worktree', + session_worktree_remove_desc: (path) => `删除位于 ${path} 的 git worktree`, + session_worktree_remove_confirm: (path) => `确定从磁盘删除 git worktree?\n\n路径:${path}\n\n整个 worktree 目录将被删除,WebUI 中的会话数据保留。`, + session_worktree_remove_not_exists: (path) => `位于 ${path} 的 worktree 在磁盘上已不存在。`, + session_worktree_remove_confirm_label: '删除', + session_worktree_removed: 'Worktree 已删除。', + session_worktree_remove_failed: '删除 worktree 失败:', + session_worktree_remove_status_failed: '读取 worktree 状态失败:', + session_worktree_remove_locked_by_stream: '无法删除 — 存在活跃的流式会话正在使用此 worktree。', + session_worktree_remove_locked_by_terminal: '无法删除 — 存在活跃的终端会话正在使用此 worktree。', + session_worktree_remove_dirty_warning: '⚠️ 此 worktree 有未提交的更改,将被永久删除。', + session_worktree_remove_untracked_warning: (count) => `${count} 个未追踪文件将被永久删除。`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} 个未推送的提交将丢失。`, session_duplicate: '复制会话', session_duplicate_desc: '用相同工作区和模型创建副本', session_duplicate_failed: '复制失败:', @@ -7743,6 +7834,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `刪除此對話?位於 ${path} 的 worktree 將保留在磁碟上。`, session_deleted: '對話已刪除', session_deleted_worktree: '對話已刪除。Worktree 仍保留在磁碟上。', + session_worktree_remove: '刪除 worktree', + session_worktree_remove_desc: (path) => `刪除位於 ${path} 的 git worktree`, + session_worktree_remove_confirm: (path) => `確定從磁碟刪除 git worktree?\n\n路徑:${path}\n\n整個 worktree 目錄將被刪除,WebUI 中的工作階段資料保留。`, + session_worktree_remove_not_exists: (path) => `位於 ${path} 的 worktree 在磁碟上已不存在。`, + session_worktree_remove_confirm_label: '刪除', + session_worktree_removed: 'Worktree 已刪除。', + session_worktree_remove_failed: '刪除 worktree 失敗:', + session_worktree_remove_status_failed: '讀取 worktree 狀態失敗:', + session_worktree_remove_locked_by_stream: '無法刪除 — 存在活躍的串流工作階段正在使用此 worktree。', + session_worktree_remove_locked_by_terminal: '無法刪除 — 存在活躍的終端機工作階段正在使用此 worktree。', + session_worktree_remove_dirty_warning: '⚠️ 此 worktree 有未提交的變更,將被永久刪除。', + session_worktree_remove_untracked_warning: (count) => `${count} 個未追蹤檔案將被永久刪除。`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} 個未推送的提交將丟失。`, session_select_mode: '選取', session_select_mode_desc: '選取會話以批次管理', session_select_all: '全選', @@ -8945,6 +9049,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `Excluir esta conversa? O worktree em ${path} permanecerá no disco.`, session_deleted: 'Conversa excluída', session_deleted_worktree: 'Conversa excluída. O worktree permanece no disco.', + session_worktree_remove: 'Remover worktree', + session_worktree_remove_desc: (path) => `Excluir o git worktree em ${path} do disco`, + session_worktree_remove_confirm: (path) => `Remover git worktree do disco?\n\nCaminho: ${path}\n\nTodo o diretório do worktree será excluído. Dados da sessão permanecem no WebUI.`, + session_worktree_remove_not_exists: (path) => `O worktree em ${path} não existe mais no disco.`, + session_worktree_remove_confirm_label: 'Remover', + session_worktree_removed: 'Worktree removido.', + session_worktree_remove_failed: 'Falha ao remover worktree: ', + session_worktree_remove_status_failed: 'Falha ao ler o status do worktree: ', + session_worktree_remove_locked_by_stream: 'Não é possível remover — uma sessão de streaming ativa está usando este worktree.', + session_worktree_remove_locked_by_terminal: 'Não é possível remover — uma sessão de terminal ativa está usando este worktree.', + session_worktree_remove_dirty_warning: 'AVISO: Este worktree tem alterações não confirmadas que serão perdidas.', + session_worktree_remove_untracked_warning: (count) => `${count} arquivo(s) não rastreados serão excluídos permanentemente.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) não enviados serão perdidos.`, session_batch_delete_worktree_confirm: 'Excluir {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.', session_batch_archive_worktree_confirm: 'Arquivar {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.', session_batch_delete_confirm: 'Excluir {0} conversas?', @@ -9947,6 +10064,19 @@ const LOCALES = { session_delete_worktree_confirm: (path) => `이 대화를 삭제하시겠습니까? ${path}의 worktree는 디스크에 남아 있습니다.`, session_deleted: '대화가 삭제되었습니다', session_deleted_worktree: '대화가 삭제되었습니다. Worktree는 디스크에 남아 있습니다.', + session_worktree_remove: '워크트리 삭제', + session_worktree_remove_desc: (path) => `${path}의 git worktree를 디스크에서 삭제합니다`, + session_worktree_remove_confirm: (path) => `git worktree를 디스크에서 삭제하시겠습니까?\n\n경로: ${path}\n\n전체 worktree 디렉토리가 삭제됩니다. 세션 데이터는 WebUI에 보존됩니다.`, + session_worktree_remove_not_exists: (path) => `${path}의 worktree가 디스크에 더 이상 존재하지 않습니다.`, + session_worktree_remove_confirm_label: '삭제', + session_worktree_removed: '워크트리가 삭제되었습니다.', + session_worktree_remove_failed: '워크트리 삭제 실패: ', + session_worktree_remove_status_failed: '워크트리 상태 읽기 실패: ', + session_worktree_remove_locked_by_stream: '삭제할 수 없습니다 — 활성 스트리밍 세션이 이 worktree를 사용 중입니다.', + session_worktree_remove_locked_by_terminal: '삭제할 수 없습니다 — 활성 터미널 세션이 이 worktree를 사용 중입니다.', + session_worktree_remove_dirty_warning: '경고: 이 worktree에는 커밋되지 않은 변경 사항이 있으며 손실됩니다.', + session_worktree_remove_untracked_warning: (count) => `${count}개의 추적되지 않은 파일이 영구적으로 삭제됩니다.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead}개의 푸시되지 않은 커밋이 손실됩니다.`, session_select_mode: '선택', session_select_mode_desc: '일괄 관리할 대화를 선택하세요', session_select_all: '전체 선택', @@ -10973,6 +11103,19 @@ const LOCALES = { session_delete_worktree_desc: 'Supprimez uniquement la conversation WebUI ; garder l\'arbre de travail sur le disque', session_deleted: 'Conversation supprimée', session_deleted_worktree: 'Conversation supprimée. Worktree reste sur le disque.', + session_worktree_remove: 'Supprimer le worktree', + session_worktree_remove_desc: (path) => `Supprimer le git worktree à ${path} du disque`, + session_worktree_remove_confirm: (path) => `Supprimer le git worktree du disque ?\n\nChemin : ${path}\n\nTout le répertoire worktree sera supprimé. Les données de session restent dans WebUI.`, + session_worktree_remove_not_exists: (path) => `Le worktree à ${path} n'existe plus sur le disque.`, + session_worktree_remove_confirm_label: 'Supprimer', + session_worktree_removed: 'Worktree supprimé.', + session_worktree_remove_failed: 'Échec de la suppression du worktree : ', + session_worktree_remove_status_failed: 'Échec de la lecture du statut du worktree : ', + session_worktree_remove_locked_by_stream: 'Impossible de supprimer — une session de streaming active utilise ce worktree.', + session_worktree_remove_locked_by_terminal: 'Impossible de supprimer — une session de terminal active utilise ce worktree.', + session_worktree_remove_dirty_warning: 'ATTENTION : Ce worktree a des modifications non validées qui seront perdues.', + session_worktree_remove_untracked_warning: (count) => `${count} fichier(s) non suivi(s) seront définitivement supprimés.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) non poussé(s) seront perdus.`, session_select_mode: 'Sélectionner', session_select_mode_desc: 'Sélectionnez les conversations à gérer par lots', session_select_all: 'Tout sélectionner', diff --git a/static/sessions.js b/static/sessions.js index 17ee262c..58d49eb9 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1666,6 +1666,18 @@ function _openSessionActionMenu(session, anchorEl){ )); } if(!isExternalSession){ + if(session.worktree_path){ + menu.appendChild(_buildSessionAction( + t('session_worktree_remove'), + t('session_worktree_remove_desc', session.worktree_path), + ICONS.trash, + async()=>{ + closeSessionActionMenu(); + await removeWorktree(session); + }, + 'danger' + )); + } menu.appendChild(_buildSessionAction( t('session_delete'), _sessionDeleteDescription(session), @@ -3152,6 +3164,72 @@ if(typeof window!=='undefined'){ }); } +async function removeWorktree(session){ + // Fetch status first + let status=null; + try{ + const statusResp=await api('/api/session/worktree/status?session_id='+encodeURIComponent(session.session_id)); + status=statusResp.status; + }catch(e){ + showToast(t('session_worktree_remove_status_failed')+e.message,0,'error'); + return; + } + if(!status){ + showToast(t('session_worktree_remove_status_failed'),0,'error'); + return; + } + // Build confirm message + let details=''; + if(!status.exists){ + details=t('session_worktree_remove_not_exists',status.path); + }else{ + details=t('session_worktree_remove_confirm',status.path); + if(status.locked_by_stream){ + showToast(t('session_worktree_remove_locked_by_stream'),0,'error'); + return; + } + if(status.locked_by_terminal){ + showToast(t('session_worktree_remove_locked_by_terminal'),0,'error'); + return; + } + if(status.dirty){ + details+='\n\n'+t('session_worktree_remove_dirty_warning'); + } + if(status.untracked_count>0){ + details+='\n'+t('session_worktree_remove_untracked_warning',status.untracked_count); + } + if(status.ahead_behind&&status.ahead_behind.ahead>0){ + details+='\n'+t('session_worktree_remove_ahead_warning',status.ahead_behind.ahead); + } + } + const ok=await showConfirmDialog({ + message:details, + confirmLabel:t('session_worktree_remove_confirm_label'), + danger:true + }); + if(!ok)return; + const force=(status.dirty||status.untracked_count>0); + try{ + const result=await api('/api/session/worktree/remove',{ + method:'POST', + body:JSON.stringify({session_id:session.session_id, force:force}) + }); + const warn=result.warnings&&result.warnings.length?(' '+result.warnings.join(' ')):''; + showToast(t('session_worktree_removed')+warn); + // Clear the worktree_path from cached session so menu doesn't show stale remove action + if(session.worktree_path){ + session.worktree_path=null; + } + // Re-render the list if this is the active session + if(S.session&&S.session.session_id===session.session_id&&S.session.worktree_path){ + S.session.worktree_path=null; + } + await renderSessionList(); + }catch(e){ + showToast(t('session_worktree_remove_failed')+e.message,0,'error'); + } +} + async function deleteSession(sid){ const session=_sessionSnapshotById(sid); const ok=await showConfirmDialog({ diff --git a/tests/test_worktree_remove.py b/tests/test_worktree_remove.py new file mode 100644 index 00000000..ad88b9c3 --- /dev/null +++ b/tests/test_worktree_remove.py @@ -0,0 +1,165 @@ +"""Tests for the worktree remove functionality (Issue #2057 Slice 2).""" + +from types import SimpleNamespace +from pathlib import Path + +import api.models as models +import api.routes as routes +import api.worktrees as worktrees + + +def _capture_post(monkeypatch, body): + captured = {} + monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) + monkeypatch.setattr(routes, "read_body", lambda handler: body) + # Monkeypatch both helpers.j and routes.j — bad() lives in helpers but calls the module-global j + import api.helpers as helpers + def _fake_j(handler, payload, status=200, extra_headers=None): + captured.update(payload=payload, status=status) + return True + monkeypatch.setattr(routes, "j", _fake_j) + monkeypatch.setattr(helpers, "j", _fake_j) + return captured + + +def _isolate_session_store(tmp_path, monkeypatch): + session_dir = tmp_path / "sessions" + session_dir.mkdir() + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") + monkeypatch.setattr(routes, "SESSION_DIR", session_dir) + monkeypatch.setattr(routes, "SESSION_INDEX_FILE", session_dir / "_index.json") + models.SESSIONS.clear() + return session_dir + + +def _make_minimal_git_repo(tmp_path): + import subprocess + main = tmp_path / "main" + main.mkdir() + subprocess.run(["git", "init", "-b", "main", str(main)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(main), "config", "user.email", "test@test.test"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(main), "config", "user.name", "Test"], check=True, capture_output=True) + (main / "file.txt").write_text("content") + subprocess.run(["git", "-C", str(main), "add", "file.txt"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(main), "commit", "-m", "init"], check=True, capture_output=True) + return main + + +# ── Function-level tests ───────────────────────────────────────────────────── + + +def test_remove_clean_worktree_succeeds(tmp_path): + import subprocess + from api.models import Session + + main = _make_minimal_git_repo(tmp_path) + wt_path = tmp_path / "wt_clean" + subprocess.run( + ["git", "-C", str(main), "worktree", "add", str(wt_path), "-b", "hermes/testclean"], + check=True, capture_output=True, + ) + assert wt_path.exists() + + s = Session( + session_id="testclean", + title="Clean", + workspace=str(wt_path), + worktree_path=str(wt_path), + worktree_branch="hermes/testclean", + worktree_repo_root=str(main), + ) + + result = worktrees.remove_worktree_for_session(s, force=False) + assert result["ok"] is True + assert result["removed_path"] == str(wt_path.resolve()) + assert not wt_path.exists() + + +def test_remove_worktree_not_exists(tmp_path): + from api.models import Session + + s = Session( + session_id="testgone", + title="Gone", + workspace=str(tmp_path / "gone"), + worktree_path=str(tmp_path / "gone"), + worktree_branch="hermes/gone", + worktree_repo_root=str(tmp_path / "repo"), + ) + + result = worktrees.remove_worktree_for_session(s, force=False) + assert result["ok"] is True + assert len(result.get("warnings", [])) >= 1 + + +def test_remove_worktree_no_path_raises(tmp_path): + from api.models import Session + + s = Session( + session_id="testnowt", + title="No worktree", + workspace=str(tmp_path), + ) + + try: + worktrees.remove_worktree_for_session(s, force=False) + assert False, "should have raised ValueError" + except ValueError as e: + assert "not worktree-backed" in str(e) + + +# ── Route-level tests ──────────────────────────────────────────────────────── + + +def test_remove_worktree_route_succeeds(tmp_path, monkeypatch): + import subprocess + from api.models import Session + + main = _make_minimal_git_repo(tmp_path) + wt_path = tmp_path / "wt_route" + subprocess.run( + ["git", "-C", str(main), "worktree", "add", str(wt_path), "-b", "hermes/testroute"], + check=True, capture_output=True, + ) + + _isolate_session_store(tmp_path, monkeypatch) + + s = Session( + session_id="testroute1", + title="Route", + workspace=str(wt_path), + worktree_path=str(wt_path), + worktree_branch="hermes/testroute", + worktree_repo_root=str(main), + ) + s.save() + + body = {"session_id": "testroute1"} + captured = _capture_post(monkeypatch, body) + + assert routes.handle_post(object(), SimpleNamespace(path="/api/session/worktree/remove")) is True + assert captured["status"] == 200 + assert captured["payload"]["ok"] is True + assert captured["payload"]["removed_path"] == str(wt_path.resolve()) + assert not wt_path.exists() + + +def test_remove_missing_session_returns_404(tmp_path, monkeypatch): + from api.models import Session + + _isolate_session_store(tmp_path, monkeypatch) + + s = Session( + session_id="someother", + title="Other", + workspace=str(tmp_path), + ) + s.save() + + body = {"session_id": "nonexistent"} + captured = _capture_post(monkeypatch, body) + + routes.handle_post(object(), SimpleNamespace(path="/api/session/worktree/remove")) + assert captured["status"] == 404 + assert "not found" in captured["payload"].get("error", "").lower() From 46c62851ad0db3307837e57cde6127dd3d82281a Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 09:49:15 +0800 Subject: [PATCH 02/15] Harden worktree removal safeguards --- api/routes.py | 30 ---- api/worktrees.py | 21 ++- static/i18n.js | 1 + static/sessions.js | 13 +- tests/test_issue2057_worktree_ui_static.py | 12 ++ tests/test_worktree_remove.py | 170 +++++++++++++++++++++ 6 files changed, 209 insertions(+), 38 deletions(-) diff --git a/api/routes.py b/api/routes.py index a78cead0..47bca9ba 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4273,36 +4273,6 @@ def handle_post(handler, parsed) -> bool: logger.debug("Failed to close workspace terminal after workspace update") set_last_workspace(new_ws) return j(handler, {"session": s.compact() | {"messages": s.messages}}) - - - - if parsed.path == "/api/session/worktree/status": - query = parse_qs(parsed.query) - sid = query.get("session_id", [""])[0] - if not sid: - return bad(handler, "session_id is required", status=400) - try: - s = get_session(sid, metadata_only=True) - except KeyError: - return bad(handler, "Session not found", status=404) - try: - from api.worktrees import worktree_status_for_session - - return j(handler, {"status": worktree_status_for_session(s)}) - except ValueError as exc: - return bad(handler, str(exc), status=400) - except Exception as exc: - logger.exception("failed to read worktree status for session %s", sid) - return bad(handler, _sanitize_error(exc), status=500) - - if parsed.path == "/api/session/compress/status": - query = parse_qs(parsed.query) - return _handle_session_compress_status(handler, query.get("session_id", [""])[0]) - - if parsed.path == "/api/session": - import time as _time - _t0 = _time.monotonic() - _debug_slow = os.environ.get("HERMES_DEBUG_SLOW", "") if parsed.path == "/api/session/worktree/remove": sid = body.get("session_id", "") if not sid or not isinstance(sid, str) or not sid.strip(): diff --git a/api/worktrees.py b/api/worktrees.py index 64929a1f..42424ee5 100644 --- a/api/worktrees.py +++ b/api/worktrees.py @@ -236,7 +236,7 @@ def remove_worktree_for_session(session, *, force: bool = False) -> dict: if status["locked_by_terminal"]: raise ValueError("Worktree is locked by an active terminal session") - # Guard: dirty / untracked files without force + # Guard: local changes and unpushed commits without explicit force. if status["dirty"] and not force: raise ValueError( "Worktree has uncommitted changes. Use force=true to override." @@ -251,17 +251,26 @@ def remove_worktree_for_session(session, *, force: bool = False) -> dict: f"Worktree has {status['untracked_count']} untracked file(s). " "Use force=true to override." ) + ahead = int((status.get("ahead_behind") or {}).get("ahead") or 0) + if ahead > 0: + if force: + warnings.append(f"{ahead} unpushed commit(s) will be removed.") + else: + raise ValueError( + f"Worktree has {ahead} unpushed commit(s). " + "Use force=true to override." + ) # Remove the worktree — must run from the repo root, not the worktree dir repo_root = getattr(session, "worktree_repo_root", None) if not repo_root: raise ValueError("Session missing worktree_repo_root") try: - result = _run_git( - ["worktree", "remove", "--force", str(worktree_path)], - str(repo_root), - timeout=10, - ) + remove_args = ["worktree", "remove"] + if force: + remove_args.append("--force") + remove_args.append(str(worktree_path)) + result = _run_git(remove_args, str(repo_root), timeout=10) except (OSError, subprocess.TimeoutExpired) as exc: raise ValueError(f"Failed to remove worktree: {exc}") from exc diff --git a/static/i18n.js b/static/i18n.js index 235c5d5e..e14b516f 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -440,6 +440,7 @@ const LOCALES = { session_worktree_remove_status_failed: 'Failed to read worktree status: ', session_worktree_remove_locked_by_stream: 'Cannot remove — an active streaming session is using this worktree.', session_worktree_remove_locked_by_terminal: 'Cannot remove — an active terminal session is using this worktree.', + session_worktree_remove_unsafe_blocked: 'Resolve local changes or unpushed commits before removing this worktree.', session_worktree_remove_dirty_warning: 'WARNING: This worktree has uncommitted changes which will be lost.', session_worktree_remove_untracked_warning: (count) => `${count} untracked file(s) will be permanently deleted.`, session_worktree_remove_ahead_warning: (ahead) => `${ahead} unpushed commit(s) will be lost.`, diff --git a/static/sessions.js b/static/sessions.js index 58d49eb9..60754a19 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3201,6 +3201,16 @@ async function removeWorktree(session){ if(status.ahead_behind&&status.ahead_behind.ahead>0){ details+='\n'+t('session_worktree_remove_ahead_warning',status.ahead_behind.ahead); } + if(status.dirty||status.untracked_count>0||(status.ahead_behind&&status.ahead_behind.ahead>0)){ + showToast(t('session_worktree_remove_failed')+t('session_worktree_remove_unsafe_blocked'),0,'error'); + await showConfirmDialog({ + message:details, + confirmLabel:t('dialog_confirm_btn'), + danger:true, + focusCancel:true + }); + return; + } } const ok=await showConfirmDialog({ message:details, @@ -3208,11 +3218,10 @@ async function removeWorktree(session){ danger:true }); if(!ok)return; - const force=(status.dirty||status.untracked_count>0); try{ const result=await api('/api/session/worktree/remove',{ method:'POST', - body:JSON.stringify({session_id:session.session_id, force:force}) + body:JSON.stringify({session_id:session.session_id, force:false}) }); const warn=result.warnings&&result.warnings.length?(' '+result.warnings.join(' ')):''; showToast(t('session_worktree_removed')+warn); diff --git a/tests/test_issue2057_worktree_ui_static.py b/tests/test_issue2057_worktree_ui_static.py index b9233280..3b99921c 100644 --- a/tests/test_issue2057_worktree_ui_static.py +++ b/tests/test_issue2057_worktree_ui_static.py @@ -68,3 +68,15 @@ def test_worktree_archive_delete_api_responses_are_explicit(): assert '"worktree_retained": True' in src assert '{"ok": True, **worktree_retained}' in src assert '{"ok": True, "session": s.compact(), **_worktree_retained_payload(s)}' in src + + +def test_remove_worktree_ui_does_not_force_unsafe_status_by_default(): + src = read("static/sessions.js") + i18n = read("static/i18n.js") + assert "async function removeWorktree(session)" in src + assert "status.dirty||status.untracked_count>0||(status.ahead_behind&&status.ahead_behind.ahead>0)" in src + assert "session_worktree_remove_unsafe_blocked" in src + assert "session_worktree_remove_unsafe_blocked" in i18n + assert "Resolve local changes or unpushed commits before removing this worktree." in i18n + assert "JSON.stringify({session_id:session.session_id, force:false})" in src + assert "const force=(status.dirty||status.untracked_count>0)" not in src diff --git a/tests/test_worktree_remove.py b/tests/test_worktree_remove.py index ad88b9c3..0c658235 100644 --- a/tests/test_worktree_remove.py +++ b/tests/test_worktree_remove.py @@ -3,6 +3,8 @@ from types import SimpleNamespace from pathlib import Path +import pytest + import api.models as models import api.routes as routes import api.worktrees as worktrees @@ -76,6 +78,167 @@ def test_remove_clean_worktree_succeeds(tmp_path): assert not wt_path.exists() +def test_remove_clean_worktree_does_not_force(tmp_path, monkeypatch): + from api.models import Session + + worktree_path = tmp_path / "wt_clean" + worktree_path.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + s = Session( + session_id="testcleanforce", + title="Clean", + workspace=str(worktree_path), + worktree_path=str(worktree_path), + worktree_branch="hermes/testcleanforce", + worktree_repo_root=str(repo_root), + ) + monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: { + "exists": True, + "dirty": False, + "untracked_count": 0, + "ahead_behind": {"ahead": 0, "behind": 0, "available": False, "upstream": None}, + "locked_by_stream": False, + "locked_by_terminal": False, + }) + calls = [] + + def fake_run_git(args, cwd, timeout=2): + calls.append(args) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(worktrees, "_run_git", fake_run_git) + + result = worktrees.remove_worktree_for_session(s, force=False) + assert result["ok"] is True + assert calls[0] == ["worktree", "remove", str(worktree_path.resolve())] + + +def test_remove_dirty_worktree_without_force_is_rejected(tmp_path, monkeypatch): + from api.models import Session + + worktree_path = tmp_path / "wt_dirty" + worktree_path.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + s = Session( + session_id="testdirty", + title="Dirty", + workspace=str(worktree_path), + worktree_path=str(worktree_path), + worktree_branch="hermes/testdirty", + worktree_repo_root=str(repo_root), + ) + monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: { + "exists": True, + "dirty": True, + "untracked_count": 0, + "ahead_behind": {"ahead": 0, "behind": 0, "available": False, "upstream": None}, + "locked_by_stream": False, + "locked_by_terminal": False, + }) + monkeypatch.setattr(worktrees, "_run_git", lambda *args, **kwargs: pytest.fail("git remove should not run")) + + with pytest.raises(ValueError, match="uncommitted changes"): + worktrees.remove_worktree_for_session(s, force=False) + + +def test_remove_untracked_worktree_without_force_is_rejected(tmp_path, monkeypatch): + from api.models import Session + + worktree_path = tmp_path / "wt_untracked" + worktree_path.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + s = Session( + session_id="testuntracked", + title="Untracked", + workspace=str(worktree_path), + worktree_path=str(worktree_path), + worktree_branch="hermes/testuntracked", + worktree_repo_root=str(repo_root), + ) + monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: { + "exists": True, + "dirty": False, + "untracked_count": 2, + "ahead_behind": {"ahead": 0, "behind": 0, "available": False, "upstream": None}, + "locked_by_stream": False, + "locked_by_terminal": False, + }) + monkeypatch.setattr(worktrees, "_run_git", lambda *args, **kwargs: pytest.fail("git remove should not run")) + + with pytest.raises(ValueError, match="untracked"): + worktrees.remove_worktree_for_session(s, force=False) + + +def test_remove_ahead_worktree_without_force_is_rejected(tmp_path, monkeypatch): + from api.models import Session + + worktree_path = tmp_path / "wt_ahead" + worktree_path.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + s = Session( + session_id="testahead", + title="Ahead", + workspace=str(worktree_path), + worktree_path=str(worktree_path), + worktree_branch="hermes/testahead", + worktree_repo_root=str(repo_root), + ) + monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: { + "exists": True, + "dirty": False, + "untracked_count": 0, + "ahead_behind": {"ahead": 1, "behind": 0, "available": True, "upstream": "origin/main"}, + "locked_by_stream": False, + "locked_by_terminal": False, + }) + monkeypatch.setattr(worktrees, "_run_git", lambda *args, **kwargs: pytest.fail("git remove should not run")) + + with pytest.raises(ValueError, match="unpushed"): + worktrees.remove_worktree_for_session(s, force=False) + + +def test_remove_force_warns_and_uses_git_force(tmp_path, monkeypatch): + from api.models import Session + + worktree_path = tmp_path / "wt_force" + worktree_path.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + s = Session( + session_id="testforce", + title="Force", + workspace=str(worktree_path), + worktree_path=str(worktree_path), + worktree_branch="hermes/testforce", + worktree_repo_root=str(repo_root), + ) + monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: { + "exists": True, + "dirty": True, + "untracked_count": 3, + "ahead_behind": {"ahead": 2, "behind": 0, "available": True, "upstream": "origin/main"}, + "locked_by_stream": False, + "locked_by_terminal": False, + }) + calls = [] + + def fake_run_git(args, cwd, timeout=2): + calls.append(args) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(worktrees, "_run_git", fake_run_git) + + result = worktrees.remove_worktree_for_session(s, force=True) + assert result["ok"] is True + assert calls[0] == ["worktree", "remove", "--force", str(worktree_path.resolve())] + assert "untracked file" in " ".join(result["warnings"]) + assert "unpushed commit" in " ".join(result["warnings"]) + + def test_remove_worktree_not_exists(tmp_path): from api.models import Session @@ -163,3 +326,10 @@ def test_remove_missing_session_returns_404(tmp_path, monkeypatch): routes.handle_post(object(), SimpleNamespace(path="/api/session/worktree/remove")) assert captured["status"] == 404 assert "not found" in captured["payload"].get("error", "").lower() + + +def test_post_router_does_not_expose_read_only_worktree_or_compress_status(): + src = Path("api/routes.py").read_text(encoding="utf-8") + post_body = src[src.index("def handle_post"):src.index('if parsed.path == "/api/session/worktree/remove"')] + assert '"/api/session/worktree/status"' not in post_body + assert '"/api/session/compress/status"' not in post_body From e177f64e78ded7995f6f13270068d0bcb83893ec Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 09:53:56 +0800 Subject: [PATCH 03/15] Add worktree remove PR screenshots --- docs/pr-media/2156/worktree-remove-confirm.png | Bin 0 -> 83246 bytes docs/pr-media/2156/worktree-remove-menu.png | Bin 0 -> 74036 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/pr-media/2156/worktree-remove-confirm.png create mode 100644 docs/pr-media/2156/worktree-remove-menu.png diff --git a/docs/pr-media/2156/worktree-remove-confirm.png b/docs/pr-media/2156/worktree-remove-confirm.png new file mode 100644 index 0000000000000000000000000000000000000000..ebbd57ad4e70eba744bd03c35764fc75eb7d3692 GIT binary patch literal 83246 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 NBc&)=B>vjxzX3rW6~O=i literal 0 HcmV?d00001 From f4efe0790b3606d03a0430f540711ef5daec1160 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 09:57:24 +0800 Subject: [PATCH 04/15] Complete worktree remove i18n coverage --- static/i18n.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index e14b516f..2b363983 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1562,6 +1562,7 @@ const LOCALES = { session_worktree_remove_status_failed: 'Lettura stato worktree fallita: ', session_worktree_remove_locked_by_stream: 'Impossibile rimuovere — una sessione di streaming attiva sta usando questo worktree.', session_worktree_remove_locked_by_terminal: 'Impossibile rimuovere — una sessione terminale attiva sta usando questo worktree.', + session_worktree_remove_unsafe_blocked: 'Risolvi le modifiche locali o i commit non inviati prima di rimuovere questo worktree.', session_worktree_remove_dirty_warning: 'ATTENZIONE: Questo worktree ha modifiche non committate che andranno perse.', session_worktree_remove_untracked_warning: (count) => `${count} file non tracciati verranno eliminati definitivamente.`, session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit non inviati andranno persi.`, @@ -2678,6 +2679,7 @@ const LOCALES = { session_worktree_remove_status_failed: 'ワークツリー状態の読み取りに失敗: ', session_worktree_remove_locked_by_stream: '削除できません — アクティブなストリーミングセッションがこのワークツリーを使用中です。', session_worktree_remove_locked_by_terminal: '削除できません — アクティブな端末セッションがこのワークツリーを使用中です。', + session_worktree_remove_unsafe_blocked: 'このワークツリーを削除する前に、ローカル変更または未プッシュのコミットを解消してください。', session_worktree_remove_dirty_warning: '警告: このワークツリーにはコミットされていない変更があり、失われます。', session_worktree_remove_untracked_warning: (count) => `${count}件の追跡されていないファイルが完全に削除されます。`, session_worktree_remove_ahead_warning: (ahead) => `${ahead}件の未プッシュコミットが失われます。`, @@ -4237,6 +4239,7 @@ const LOCALES = { session_worktree_remove_status_failed: 'Failed to read worktree status: ', session_worktree_remove_locked_by_stream: 'Cannot remove — an active streaming session is using this worktree.', session_worktree_remove_locked_by_terminal: 'Cannot remove — an active terminal session is using this worktree.', + session_worktree_remove_unsafe_blocked: 'Resolve local changes or unpushed commits before removing this worktree.', session_worktree_remove_dirty_warning: 'WARNING: This worktree has uncommitted changes which will be lost.', session_worktree_remove_untracked_warning: (count) => `${count} untracked file(s) will be permanently deleted.`, session_worktree_remove_ahead_warning: (ahead) => `${ahead} unpushed commit(s) will be lost.`, @@ -5280,6 +5283,7 @@ const LOCALES = { session_worktree_remove_status_failed: 'Error al leer el estado del worktree: ', session_worktree_remove_locked_by_stream: 'No se puede eliminar — una sesión de streaming activa está usando este worktree.', session_worktree_remove_locked_by_terminal: 'No se puede eliminar — una sesión de terminal activa está usando este worktree.', + session_worktree_remove_unsafe_blocked: 'Resuelve los cambios locales o los commits no enviados antes de eliminar este worktree.', session_worktree_remove_dirty_warning: 'ADVERTENCIA: Este worktree tiene cambios no confirmados que se perderán.', session_worktree_remove_untracked_warning: (count) => `${count} archivo(s) no rastreados se eliminarán permanentemente.`, session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) no enviados se perderán.`, @@ -6063,6 +6067,7 @@ const LOCALES = { session_worktree_remove_status_failed: 'Fehler beim Lesen des Worktree-Status: ', session_worktree_remove_locked_by_stream: 'Entfernen nicht möglich — eine aktive Streaming-Sitzung verwendet diesen Worktree.', session_worktree_remove_locked_by_terminal: 'Entfernen nicht möglich — eine aktive Terminal-Sitzung verwendet diesen Worktree.', + session_worktree_remove_unsafe_blocked: 'Löse lokale Änderungen oder nicht gepushte Commits, bevor du diesen Worktree entfernst.', session_worktree_remove_dirty_warning: 'WARNUNG: Dieser Worktree hat nicht festgeschriebene Änderungen, die verloren gehen.', session_worktree_remove_untracked_warning: (count) => `${count} nicht verfolgte Datei(en) werden dauerhaft gelöscht.`, session_worktree_remove_ahead_warning: (ahead) => `${ahead} nicht gepushte Commit(s) gehen verloren.`, @@ -7387,6 +7392,7 @@ const LOCALES = { session_worktree_remove_status_failed: '读取 worktree 状态失败:', session_worktree_remove_locked_by_stream: '无法删除 — 存在活跃的流式会话正在使用此 worktree。', session_worktree_remove_locked_by_terminal: '无法删除 — 存在活跃的终端会话正在使用此 worktree。', + session_worktree_remove_unsafe_blocked: '请先处理本地更改或未推送提交,再删除此 worktree。', session_worktree_remove_dirty_warning: '⚠️ 此 worktree 有未提交的更改,将被永久删除。', session_worktree_remove_untracked_warning: (count) => `${count} 个未追踪文件将被永久删除。`, session_worktree_remove_ahead_warning: (ahead) => `${ahead} 个未推送的提交将丢失。`, @@ -7845,6 +7851,7 @@ const LOCALES = { session_worktree_remove_status_failed: '讀取 worktree 狀態失敗:', session_worktree_remove_locked_by_stream: '無法刪除 — 存在活躍的串流工作階段正在使用此 worktree。', session_worktree_remove_locked_by_terminal: '無法刪除 — 存在活躍的終端機工作階段正在使用此 worktree。', + session_worktree_remove_unsafe_blocked: '請先處理本機變更或未推送提交,再刪除此 worktree。', session_worktree_remove_dirty_warning: '⚠️ 此 worktree 有未提交的變更,將被永久刪除。', session_worktree_remove_untracked_warning: (count) => `${count} 個未追蹤檔案將被永久刪除。`, session_worktree_remove_ahead_warning: (ahead) => `${ahead} 個未推送的提交將丟失。`, @@ -9060,6 +9067,7 @@ const LOCALES = { session_worktree_remove_status_failed: 'Falha ao ler o status do worktree: ', session_worktree_remove_locked_by_stream: 'Não é possível remover — uma sessão de streaming ativa está usando este worktree.', session_worktree_remove_locked_by_terminal: 'Não é possível remover — uma sessão de terminal ativa está usando este worktree.', + session_worktree_remove_unsafe_blocked: 'Resolva alterações locais ou commits não enviados antes de remover este worktree.', session_worktree_remove_dirty_warning: 'AVISO: Este worktree tem alterações não confirmadas que serão perdidas.', session_worktree_remove_untracked_warning: (count) => `${count} arquivo(s) não rastreados serão excluídos permanentemente.`, session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) não enviados serão perdidos.`, @@ -10075,6 +10083,7 @@ const LOCALES = { session_worktree_remove_status_failed: '워크트리 상태 읽기 실패: ', session_worktree_remove_locked_by_stream: '삭제할 수 없습니다 — 활성 스트리밍 세션이 이 worktree를 사용 중입니다.', session_worktree_remove_locked_by_terminal: '삭제할 수 없습니다 — 활성 터미널 세션이 이 worktree를 사용 중입니다.', + session_worktree_remove_unsafe_blocked: '이 worktree를 삭제하기 전에 로컬 변경 사항이나 푸시되지 않은 커밋을 정리하세요.', session_worktree_remove_dirty_warning: '경고: 이 worktree에는 커밋되지 않은 변경 사항이 있으며 손실됩니다.', session_worktree_remove_untracked_warning: (count) => `${count}개의 추적되지 않은 파일이 영구적으로 삭제됩니다.`, session_worktree_remove_ahead_warning: (ahead) => `${ahead}개의 푸시되지 않은 커밋이 손실됩니다.`, @@ -11114,6 +11123,7 @@ const LOCALES = { session_worktree_remove_status_failed: 'Échec de la lecture du statut du worktree : ', session_worktree_remove_locked_by_stream: 'Impossible de supprimer — une session de streaming active utilise ce worktree.', session_worktree_remove_locked_by_terminal: 'Impossible de supprimer — une session de terminal active utilise ce worktree.', + session_worktree_remove_unsafe_blocked: 'Résolvez les modifications locales ou les commits non poussés avant de supprimer ce worktree.', session_worktree_remove_dirty_warning: 'ATTENTION : Ce worktree a des modifications non validées qui seront perdues.', session_worktree_remove_untracked_warning: (count) => `${count} fichier(s) non suivi(s) seront définitivement supprimés.`, session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) non poussé(s) seront perdus.`, From 9ea4f1145dac0d98a435f7ddc3a3a112b410240b Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 10:23:03 +0800 Subject: [PATCH 05/15] Fix stale stream exception writeback guards --- CHANGELOG.md | 1 + api/streaming.py | 16 ++++++++++++++++ tests/test_stale_stream_writeback.py | 26 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d737b24..7ca359c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Stale stream ownership checks now also cover the credential self-heal retry success path and the outer exception error persistence path, preventing an old worker from saving retry results or error markers after the active stream has rotated (refs #2154). - **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior. ### Added diff --git a/api/streaming.py b/api/streaming.py index 09022ff2..ff1e4a07 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -3946,6 +3946,14 @@ def _run_agent_streaming( _ckpt_thread.join(timeout=15) _lock_ctx = _agent_lock if _agent_lock is not None else contextlib.nullcontext() with _lock_ctx: + if not ephemeral and not _stream_writeback_is_current(s, stream_id): + logger.info( + "Skipping stale stream self-heal writeback for session %s stream %s; active_stream_id=%s", + getattr(s, 'session_id', session_id), + stream_id, + getattr(s, 'active_stream_id', None), + ) + return _result_messages = _heal_result.get('messages') or _previous_context_messages _next_context_messages = _restore_reasoning_metadata( _previous_context_messages, _result_messages, @@ -3987,6 +3995,14 @@ def _run_agent_streaming( # API calls so the LLM never sees its own error as prior context on the next turn. _lock_ctx = _agent_lock if _agent_lock is not None else contextlib.nullcontext() with _lock_ctx: + if not ephemeral and not _stream_writeback_is_current(s, stream_id): + logger.info( + "Skipping stale stream error writeback for session %s stream %s; active_stream_id=%s", + getattr(s, 'session_id', session_id), + stream_id, + getattr(s, 'active_stream_id', None), + ) + return _materialize_pending_user_turn_before_error(s) s.active_stream_id = None s.pending_user_message = None diff --git a/tests/test_stale_stream_writeback.py b/tests/test_stale_stream_writeback.py index 03aa55da..1a4d2398 100644 --- a/tests/test_stale_stream_writeback.py +++ b/tests/test_stale_stream_writeback.py @@ -87,3 +87,29 @@ def test_success_path_checks_stream_ownership_before_persisting_result(): assert compression_pos != -1 assert guard_pos < result_merge_pos assert guard_pos < compression_pos + + +def test_self_heal_retry_success_checks_stream_ownership_before_writeback(): + src = Path("api/streaming.py").read_text(encoding="utf-8") + start = src.index("logger.info('[webui] self-heal (except path): retrying stream") + end = src.index("logger.info('[webui] self-heal (except path): retry succeeded')", start) + block = src[start:end] + guard = "if not ephemeral and not _stream_writeback_is_current(s, stream_id):" + + assert guard in block + assert block.index(guard) < block.index("_result_messages = _heal_result.get('messages') or _previous_context_messages") + assert block.index(guard) < block.index("s.save()") + + +def test_outer_exception_path_checks_stream_ownership_before_error_writeback(): + src = Path("api/streaming.py").read_text(encoding="utf-8") + outer_error_payload = src.index("_error_payload = _provider_error_payload(err_str, _exc_type, _exc_hint)") + start = src.index("# Persist the error so it survives page reload.", outer_error_payload) + end = src.index("put('apperror', _error_payload)", start) + block = src[start:end] + guard = "if not ephemeral and not _stream_writeback_is_current(s, stream_id):" + + assert guard in block + assert block.index(guard) < block.index("_materialize_pending_user_turn_before_error(s)") + assert block.index(guard) < block.index("s.active_stream_id = None") + assert block.index(guard) < block.index("s.messages.append(_error_message)") From 5ae63ddd131ef72561d7a7bbd3f31d1db429ab54 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 10:26:09 +0800 Subject: [PATCH 06/15] Fix stale stream state in session list --- CHANGELOG.md | 1 + api/routes.py | 30 +++++ ...ue2157_sessions_list_stale_stream_state.py | 104 ++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 tests/test_issue2157_sessions_list_stale_stream_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d737b24..4bdcc956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- `/api/sessions` now reconciles stale persisted stream state before serializing sidebar rows, so sessions left with a dead `active_stream_id` after a restart or worker crash stop advertising stale runtime fields in the list response (refs #2157). - **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior. ### Added diff --git a/api/routes.py b/api/routes.py index 65f12285..bee2bfee 100644 --- a/api/routes.py +++ b/api/routes.py @@ -953,6 +953,32 @@ def _clear_stale_stream_state(session) -> bool: pass return True + +def _reconcile_stale_stream_state_for_session_rows(session_rows) -> bool: + """Clear stale persisted stream fields before /api/sessions serializes rows.""" + changed = False + for row in session_rows: + if not isinstance(row, dict): + continue + sid = row.get("session_id") + if not sid or not row.get("active_stream_id"): + continue + if row.get("is_streaming") is True: + continue + try: + session = get_session(sid, metadata_only=True) + except Exception: + logger.debug( + "Failed to load session %s while reconciling stale stream state", + sid, + exc_info=True, + ) + continue + if session is None: + continue + changed = _clear_stale_stream_state(session) or changed + return changed + # ── CSRF: validate Origin/Referer on POST ──────────────────────────────────── import re as _re @@ -3385,6 +3411,10 @@ def handle_get(handler, parsed) -> bool: try: diag.stage("all_sessions") webui_sessions = all_sessions(diag=diag) + diag.stage("reconcile_stale_stream_state") + if _reconcile_stale_stream_state_for_session_rows(webui_sessions): + diag.stage("all_sessions_after_stale_stream_reconcile") + webui_sessions = all_sessions(diag=diag) diag.stage("load_settings") settings = load_settings() show_cli_sessions = bool(settings.get("show_cli_sessions")) diff --git a/tests/test_issue2157_sessions_list_stale_stream_state.py b/tests/test_issue2157_sessions_list_stale_stream_state.py new file mode 100644 index 00000000..251fabdb --- /dev/null +++ b/tests/test_issue2157_sessions_list_stale_stream_state.py @@ -0,0 +1,104 @@ +import io +import json +from urllib.parse import urlparse + +import api.profiles as profiles +import api.routes as routes + + +class _FakeHandler: + def __init__(self): + self.status = None + self.headers = {} + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.headers[key] = value + + def end_headers(self): + pass + + def json_body(self): + return json.loads(self.wfile.getvalue().decode("utf-8")) + + +def test_sessions_list_reconciles_stale_stream_state_before_serializing(monkeypatch): + repaired = {"value": False} + all_sessions_calls = {"count": 0} + + class _Session: + def __init__(self): + self.session_id = "stale-session" + self.active_stream_id = "stale-stream" + + def fake_all_sessions(diag=None): + all_sessions_calls["count"] += 1 + if repaired["value"]: + active_stream_id = None + is_streaming = False + else: + active_stream_id = "stale-stream" + is_streaming = False + return [ + { + "session_id": "stale-session", + "title": "Stale Session", + "profile": "default", + "active_stream_id": active_stream_id, + "is_streaming": is_streaming, + "updated_at": 1, + "last_message_at": 1, + } + ] + + def fake_get_session(session_id, metadata_only=False): + assert session_id == "stale-session" + assert metadata_only is True + return _Session() + + def fake_clear_stale_stream_state(session): + repaired["value"] = True + session.active_stream_id = None + return True + + monkeypatch.setattr(routes, "all_sessions", fake_all_sessions) + monkeypatch.setattr(routes, "get_session", fake_get_session) + monkeypatch.setattr(routes, "_clear_stale_stream_state", fake_clear_stale_stream_state) + monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": False}) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") + + handler = _FakeHandler() + parsed = urlparse("http://example.com/api/sessions") + routes.handle_get(handler, parsed) + + assert handler.status == 200 + payload = handler.json_body() + sessions = payload["sessions"] + assert all_sessions_calls["count"] == 2 + assert repaired["value"] is True + assert sessions[0]["active_stream_id"] is None + assert sessions[0]["is_streaming"] is False + + +def test_reconcile_stale_stream_state_skips_live_stream_rows(monkeypatch): + loaded = [] + + def fake_get_session(session_id, metadata_only=False): + loaded.append((session_id, metadata_only)) + raise AssertionError("live stream rows should not be loaded for cleanup") + + monkeypatch.setattr(routes, "get_session", fake_get_session) + + changed = routes._reconcile_stale_stream_state_for_session_rows([ + { + "session_id": "live-session", + "active_stream_id": "live-stream", + "is_streaming": True, + } + ]) + + assert changed is False + assert loaded == [] From 57ee0ce0696a088d97296e8d59e90ecc39bca0a0 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 10:52:59 +0800 Subject: [PATCH 07/15] Add CSP report collector endpoint --- CHANGELOG.md | 1 + api/routes.py | 77 ++++++++++++++++ server.py | 7 +- tests/test_issue1909_csp_report_only.py | 117 ++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d737b24..063db4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- `POST /api/csp-report` now collects browser CSP report-only violations and the report-only policy advertises both `report-uri` and `report-to` sinks, making CSP dry-runs visible outside individual browser devtools consoles (closes #2095). - **PR #2150** by @Jordan-SkyLF — "Refresh usage" button on the Provider quota card in Settings → Providers. Calls `/api/provider/quota?refresh=1&ts=` with `cache: 'no-store'` to bypass browser, service worker, and reverse-proxy caches that may have stamped a previous quota response, then re-renders just the quota card from the fresh response and shows a `Last checked ...` timestamp. Disabled `Refreshing…` state during the in-flight request; success toast on completion or failure toast if the refresh fails. Note: the `refresh=1` query param is a no-op at the server today (`get_provider_quota()` has no in-process cache layer), so the win is strictly browser-side cache-bust + the `no-store` fetch option. A future maintainer follow-up may add server-side TTL caching of OAuth account-limit fetches, at which point the `refresh=1` param becomes load-bearing on both sides. ## [v0.51.51] — 2026-05-12 — Release AA (stage-344 — 16-PR contributor batch — i18n + insights bucketing/mobile + manual-compress async + workspace recovery + iOS PWA scroll + Cloudflare login health + fr locale) diff --git a/api/routes.py b/api/routes.py index 65f12285..801e4907 100644 --- a/api/routes.py +++ b/api/routes.py @@ -63,6 +63,12 @@ _MESSAGING_SESSION_METADATA_CACHE: dict[str, object] = { } _MESSAGING_SESSION_METADATA_LOCK = threading.Lock() _STALE_MESSAGING_END_REASONS = {"session_reset", "session_switch"} +_CSP_REPORT_LOGGER = logging.getLogger("csp_report") +_CSP_REPORT_RATE_LIMIT: dict[str, list[float]] = {} +_CSP_REPORT_RATE_LIMIT_LOCK = threading.Lock() +_CSP_REPORT_RATE_LIMIT_WINDOW_SECONDS = 60 +_CSP_REPORT_RATE_LIMIT_MAX = 100 +_CSP_REPORT_MAX_BODY_BYTES = 64 * 1024 # ── Profile-scoped session/project filtering (#1611, #1614) ──────────────── @@ -1057,6 +1063,69 @@ def _check_csrf(handler) -> bool: return False +def _client_ip_for_rate_limit(handler) -> str: + try: + address = getattr(handler, "client_address", None) + if address: + return str(address[0]) + except Exception: + pass + return "unknown" + + +def _csp_report_rate_limited(handler, *, now: float | None = None) -> bool: + now = time.time() if now is None else now + key = _client_ip_for_rate_limit(handler) + cutoff = now - _CSP_REPORT_RATE_LIMIT_WINDOW_SECONDS + with _CSP_REPORT_RATE_LIMIT_LOCK: + timestamps = [ts for ts in _CSP_REPORT_RATE_LIMIT.get(key, []) if ts >= cutoff] + if len(timestamps) >= _CSP_REPORT_RATE_LIMIT_MAX: + _CSP_REPORT_RATE_LIMIT[key] = timestamps + return True + timestamps.append(now) + _CSP_REPORT_RATE_LIMIT[key] = timestamps + return False + + +def _send_no_content(handler, status: int = 204) -> bool: + handler.send_response(status) + handler.send_header("Content-Length", "0") + handler.end_headers() + return True + + +def _read_csp_report_payload(handler): + try: + length = int(handler.headers.get("Content-Length", 0)) + except Exception: + length = 0 + if length > _CSP_REPORT_MAX_BODY_BYTES: + try: + handler.rfile.read(_CSP_REPORT_MAX_BODY_BYTES) + except Exception: + pass + return {"discarded": "body_too_large", "bytes": length} + raw = handler.rfile.read(length) if length else b"{}" + try: + return json.loads(raw.decode("utf-8")) + except Exception: + return {"invalid": True, "bytes": len(raw)} + + +def _handle_csp_report(handler) -> bool: + """Collect browser CSP report-only violations without requiring auth.""" + if _csp_report_rate_limited(handler): + _CSP_REPORT_LOGGER.warning( + "Dropped CSP report from %s: rate limit exceeded", + _client_ip_for_rate_limit(handler), + ) + return _send_no_content(handler) + + payload = _read_csp_report_payload(handler) + _CSP_REPORT_LOGGER.info("CSP report from %s: %s", _client_ip_for_rate_limit(handler), payload) + return _send_no_content(handler) + + def _normalize_provider_id(value: str | None) -> str: raw = str(value or "").strip().lower() if not raw: @@ -3872,6 +3941,14 @@ def handle_get(handler, parsed) -> bool: def handle_post(handler, parsed) -> bool: """Handle all POST routes. Returns True if handled, False for 404.""" diag = RequestDiagnostics.maybe_start("POST", parsed.path, logger=logger) + if parsed.path == "/api/csp-report": + if diag: + diag.stage("csp_report") + try: + return _handle_csp_report(handler) + finally: + if diag: + diag.finish() # CSRF: reject cross-origin browser requests if diag: diag.stage("csrf") diff --git a/server.py b/server.py index 9768422f..25aa110e 100644 --- a/server.py +++ b/server.py @@ -210,8 +210,10 @@ class Handler(BaseHTTPRequestHandler): "img-src 'self' data: blob:; " "font-src 'self' data:; " "media-src 'self' data: blob:; " - "connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*" + "connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; " + "report-uri /api/csp-report; report-to csp-endpoint" ) + _CSP_REPORT_TO = '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/api/csp-report"}]}' @classmethod def csp_report_only_policy(cls) -> str: @@ -219,6 +221,7 @@ class Handler(BaseHTTPRequestHandler): def end_headers(self) -> None: self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy()) + self.send_header("Report-To", self._CSP_REPORT_TO) super().end_headers() def log_message(self, fmt, *args): pass # suppress default Apache-style log @@ -262,7 +265,7 @@ class Handler(BaseHTTPRequestHandler): set_request_profile(cookie_profile) try: parsed = urlparse(self.path) - if not check_auth(self, parsed): return + if parsed.path != "/api/csp-report" and not check_auth(self, parsed): return result = route_func(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) diff --git a/tests/test_issue1909_csp_report_only.py b/tests/test_issue1909_csp_report_only.py index 56d8f282..ca251450 100644 --- a/tests/test_issue1909_csp_report_only.py +++ b/tests/test_issue1909_csp_report_only.py @@ -1,7 +1,11 @@ """Regression tests for #1909 CSP report-only security header.""" +import io +import json from http.server import BaseHTTPRequestHandler +from types import SimpleNamespace +import api.routes as routes from server import Handler @@ -15,12 +19,20 @@ def test_handler_adds_content_security_policy_report_only(monkeypatch): headers = dict(sent_headers) assert "Content-Security-Policy-Report-Only" in headers + assert "Report-To" in headers assert "Content-Security-Policy" not in headers policy = headers["Content-Security-Policy-Report-Only"] assert "default-src 'self'" in policy assert "object-src 'none'" in policy assert "frame-ancestors 'self'" in policy assert "base-uri 'self'" in policy + assert "report-uri /api/csp-report" in policy + assert "report-to csp-endpoint" in policy + assert json.loads(headers["Report-To"]) == { + "group": "csp-endpoint", + "max_age": 10886400, + "endpoints": [{"url": "/api/csp-report"}], + } def test_csp_report_only_keeps_legacy_inline_allowances_for_current_ui(): @@ -33,3 +45,108 @@ def test_csp_report_only_keeps_legacy_inline_allowances_for_current_ui(): assert "'unsafe-eval'" not in policy assert "img-src 'self' data: blob:" in policy assert "connect-src 'self'" in policy + + +class _FakeHandler: + def __init__(self, body=b"{}", headers=None, client_ip="203.0.113.10"): + self.headers = { + "Content-Length": str(len(body)), + "Content-Type": "application/csp-report", + **(headers or {}), + } + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.client_address = (client_ip, 54321) + self.status = None + self.sent_headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.sent_headers[key] = value + + def end_headers(self): + pass + + +def test_csp_report_endpoint_accepts_report_uri_payload_without_csrf(monkeypatch, caplog): + routes._CSP_REPORT_RATE_LIMIT.clear() + payload = { + "csp-report": { + "document-uri": "http://127.0.0.1:8787/", + "violated-directive": "script-src-elem", + "blocked-uri": "inline", + } + } + handler = _FakeHandler(json.dumps(payload).encode("utf-8")) + + def fail_if_called(_handler): + raise AssertionError("CSP reports must bypass the normal CSRF gate") + + monkeypatch.setattr(routes, "_check_csrf", fail_if_called) + + with caplog.at_level("INFO", logger="csp_report"): + assert routes.handle_post(handler, SimpleNamespace(path="/api/csp-report")) is True + + assert handler.status == 204 + assert handler.sent_headers["Content-Length"] == "0" + assert "violated-directive" in caplog.text + + +def test_csp_report_endpoint_accepts_report_to_array_payload(): + routes._CSP_REPORT_RATE_LIMIT.clear() + payload = [ + { + "type": "csp-violation", + "url": "http://127.0.0.1:8787/", + "body": {"blockedURL": "https://example.invalid/script.js"}, + } + ] + handler = _FakeHandler( + json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/reports+json"}, + ) + + assert routes.handle_post(handler, SimpleNamespace(path="/api/csp-report")) is True + + assert handler.status == 204 + assert handler.sent_headers["Content-Length"] == "0" + + +def test_csp_report_endpoint_rate_limits_by_client_ip(monkeypatch): + routes._CSP_REPORT_RATE_LIMIT.clear() + monkeypatch.setattr(routes, "_CSP_REPORT_RATE_LIMIT_MAX", 1) + first = _FakeHandler(b"{}", client_ip="203.0.113.11") + second = _FakeHandler(b"{}", client_ip="203.0.113.11") + + assert routes.handle_post(first, SimpleNamespace(path="/api/csp-report")) is True + assert routes.handle_post(second, SimpleNamespace(path="/api/csp-report")) is True + + assert first.status == 204 + assert second.status == 204 + assert second.rfile.tell() == 0 + + +def test_server_bypasses_auth_for_csp_report(monkeypatch): + handler = Handler.__new__(Handler) + handler.path = "/api/csp-report" + handler.command = "POST" + handler._req_t0 = 0 + + def fail_auth(_handler, _parsed): + raise AssertionError("CSP report collector must not require auth") + + called = {} + + def fake_route(_handler, parsed): + called["path"] = parsed.path + return True + + monkeypatch.setattr("server.check_auth", fail_auth) + monkeypatch.setattr("server.clear_request_profile", lambda: None) + monkeypatch.setattr("server.get_profile_cookie", lambda _handler: None) + + Handler._handle_write(handler, fake_route) + + assert called == {"path": "/api/csp-report"} From f1ca07c1867ff32daaf3e741dbb3c2c6ee04e3d8 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 10:55:59 +0800 Subject: [PATCH 08/15] Localize logs severity filters --- CHANGELOG.md | 1 + static/i18n.js | 75 ++++++++++--------- tests/test_issue2098_logs_i18n.py | 119 ++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 35 deletions(-) create mode 100644 tests/test_issue2098_logs_i18n.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d737b24..e785b783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Logs severity/filter i18n keys are now localized for Japanese, Russian, Spanish, German, Simplified Chinese, Traditional Chinese, Portuguese, and Korean instead of falling back to English TODO placeholders (closes #2098). - **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior. ### Added diff --git a/static/i18n.js b/static/i18n.js index 4cdd841c..34576214 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -2865,11 +2865,11 @@ const LOCALES = { logs_no_mtime: '未書き込み', logs_truncated_hint: '大きなログファイルの末尾を表示しています。メモリ使用量を抑えるため、古いデータは省略されました。', logs_copied: 'ログをコピーしました', - logs_severity: 'Severity', // TODO: translate - logs_severity_all: 'All', // TODO: translate - logs_severity_errors: 'Errors', // TODO: translate - logs_severity_warnings: 'Warnings+', // TODO: translate - logs_filter_active: 'shown (filter active)', // TODO: translate + logs_severity: '重大度', + logs_severity_all: 'すべて', + logs_severity_errors: 'エラー', + logs_severity_warnings: '警告+', + logs_filter_active: '表示中(フィルター有効)', // Insights insights_title: '使用状況分析', @@ -3778,11 +3778,11 @@ const LOCALES = { logs_no_mtime: 'not written yet', // TODO: translate logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate logs_copied: 'Logs copied', // TODO: translate - logs_severity: 'Severity', // TODO: translate - logs_severity_all: 'All', // TODO: translate - logs_severity_errors: 'Errors', // TODO: translate - logs_severity_warnings: 'Warnings+', // TODO: translate - logs_filter_active: 'shown (filter active)', // TODO: translate + logs_severity: 'Уровень', + logs_severity_all: 'Все', + logs_severity_errors: 'Ошибки', + logs_severity_warnings: 'Предупреждения+', + logs_filter_active: 'показано (фильтр активен)', new_conversation: 'Новая беседа', filter_conversations: 'Фильтр бесед...', session_time_unknown: 'Неизвестно', @@ -4820,11 +4820,11 @@ const LOCALES = { logs_no_mtime: 'not written yet', // TODO: translate logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate logs_copied: 'Logs copied', // TODO: translate - logs_severity: 'Severity', // TODO: translate - logs_severity_all: 'All', // TODO: translate - logs_severity_errors: 'Errors', // TODO: translate - logs_severity_warnings: 'Warnings+', // TODO: translate - logs_filter_active: 'shown (filter active)', // TODO: translate + logs_severity: 'Severidad', + logs_severity_all: 'Todo', + logs_severity_errors: 'Errores', + logs_severity_warnings: 'Advertencias+', + logs_filter_active: 'mostrados (filtro activo)', new_conversation: 'Nueva conversación', filter_conversations: 'Filtrar conversaciones...', session_time_unknown: 'Desconocido', @@ -5845,11 +5845,11 @@ const LOCALES = { logs_no_mtime: 'not written yet', // TODO: translate logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate logs_copied: 'Logs copied', // TODO: translate - logs_severity: 'Severity', // TODO: translate - logs_severity_all: 'All', // TODO: translate - logs_severity_errors: 'Errors', // TODO: translate - logs_severity_warnings: 'Warnings+', // TODO: translate - logs_filter_active: 'shown (filter active)', // TODO: translate + logs_severity: 'Schweregrad', + logs_severity_all: 'Alle', + logs_severity_errors: 'Fehler', + logs_severity_warnings: 'Warnungen+', + logs_filter_active: 'angezeigt (Filter aktiv)', new_conversation: 'Neuer Chat', filter_conversations: 'Chats filtern...', scheduled_jobs: 'Geplante Aufgaben', @@ -6903,11 +6903,11 @@ const LOCALES = { logs_no_mtime: '尚未写入', logs_truncated_hint: '此处显示的是日志文件的末尾内容。为节省内存,已省略较早的数据。', logs_copied: '日志已复制', - logs_severity: 'Severity', // TODO: translate - logs_severity_all: 'All', // TODO: translate - logs_severity_errors: 'Errors', // TODO: translate - logs_severity_warnings: 'Warnings+', // TODO: translate - logs_filter_active: 'shown (filter active)', // TODO: translate + logs_severity: '严重性', + logs_severity_all: '全部', + logs_severity_errors: '错误', + logs_severity_warnings: '警告+', + logs_filter_active: '已显示(筛选器已启用)', new_conversation: '新建对话', filter_conversations: '筛选对话…', session_time_unknown: '未知', @@ -7933,6 +7933,11 @@ const LOCALES = { kanban_dispatch_auto_blocked: '自動封鎖', kanban_dispatch_timed_out: '逾時', kanban_dispatch_crashed: '崩潰', + logs_severity: '嚴重性', + logs_severity_all: '全部', + logs_severity_errors: '錯誤', + logs_severity_warnings: '警告+', + logs_filter_active: '已顯示(篩選器已啟用)', new_conversation: '新對話', filter_conversations: '篩選對話', scheduled_jobs: '排程任務', @@ -9138,11 +9143,11 @@ const LOCALES = { logs_no_mtime: 'not written yet', // TODO: translate logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate logs_copied: 'Logs copied', // TODO: translate - logs_severity: 'Severity', // TODO: translate - logs_severity_all: 'All', // TODO: translate - logs_severity_errors: 'Errors', // TODO: translate - logs_severity_warnings: 'Warnings+', // TODO: translate - logs_filter_active: 'shown (filter active)', // TODO: translate + logs_severity: 'Severidade', + logs_severity_all: 'Todos', + logs_severity_errors: 'Erros', + logs_severity_warnings: 'Avisos+', + logs_filter_active: 'exibidos (filtro ativo)', new_conversation: 'Nova conversa', filter_conversations: 'Filtrar conversas...', session_time_unknown: 'Desconhecido', @@ -10144,11 +10149,11 @@ const LOCALES = { logs_no_mtime: 'not written yet', // TODO: translate logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate logs_copied: 'Logs copied', // TODO: translate - logs_severity: 'Severity', // TODO: translate - logs_severity_all: 'All', // TODO: translate - logs_severity_errors: 'Errors', // TODO: translate - logs_severity_warnings: 'Warnings+', // TODO: translate - logs_filter_active: 'shown (filter active)', // TODO: translate + logs_severity: '심각도', + logs_severity_all: '전체', + logs_severity_errors: '오류', + logs_severity_warnings: '경고+', + logs_filter_active: '표시됨(필터 활성)', new_conversation: '새 대화', filter_conversations: '대화 필터…', session_time_unknown: 'Unknown', diff --git a/tests/test_issue2098_logs_i18n.py b/tests/test_issue2098_logs_i18n.py new file mode 100644 index 00000000..84ee1761 --- /dev/null +++ b/tests/test_issue2098_logs_i18n.py @@ -0,0 +1,119 @@ +import re +from pathlib import Path + + +I18N_PATH = Path(__file__).resolve().parent.parent / "static" / "i18n.js" + + +LOGS_FILTER_KEYS = { + "ja": { + "logs_severity": "重大度", + "logs_severity_all": "すべて", + "logs_severity_errors": "エラー", + "logs_severity_warnings": "警告+", + "logs_filter_active": "表示中(フィルター有効)", + }, + "ru": { + "logs_severity": "Уровень", + "logs_severity_all": "Все", + "logs_severity_errors": "Ошибки", + "logs_severity_warnings": "Предупреждения+", + "logs_filter_active": "показано (фильтр активен)", + }, + "es": { + "logs_severity": "Severidad", + "logs_severity_all": "Todo", + "logs_severity_errors": "Errores", + "logs_severity_warnings": "Advertencias+", + "logs_filter_active": "mostrados (filtro activo)", + }, + "de": { + "logs_severity": "Schweregrad", + "logs_severity_all": "Alle", + "logs_severity_errors": "Fehler", + "logs_severity_warnings": "Warnungen+", + "logs_filter_active": "angezeigt (Filter aktiv)", + }, + "zh": { + "logs_severity": "严重性", + "logs_severity_all": "全部", + "logs_severity_errors": "错误", + "logs_severity_warnings": "警告+", + "logs_filter_active": "已显示(筛选器已启用)", + }, + "zh-Hant": { + "logs_severity": "嚴重性", + "logs_severity_all": "全部", + "logs_severity_errors": "錯誤", + "logs_severity_warnings": "警告+", + "logs_filter_active": "已顯示(篩選器已啟用)", + }, + "pt": { + "logs_severity": "Severidade", + "logs_severity_all": "Todos", + "logs_severity_errors": "Erros", + "logs_severity_warnings": "Avisos+", + "logs_filter_active": "exibidos (filtro ativo)", + }, + "ko": { + "logs_severity": "심각도", + "logs_severity_all": "전체", + "logs_severity_errors": "오류", + "logs_severity_warnings": "경고+", + "logs_filter_active": "표시됨(필터 활성)", + }, +} + + +def _i18n_locale_block(locale: str) -> str: + src = I18N_PATH.read_text(encoding="utf-8") + if "-" in locale: + head = re.compile(rf"^ '{re.escape(locale)}':\s*\{{", re.M) + else: + head = re.compile(rf"^ {re.escape(locale)}:\s*\{{", re.M) + match = head.search(src) + assert match, f"locale {locale!r} not found" + body_start = match.end() + depth = 1 + i = body_start + while i < len(src) and depth > 0: + ch = src[i] + if ch == "/" and i + 1 < len(src) and src[i + 1] == "/": + newline = src.find("\n", i) + i = len(src) if newline < 0 else newline + 1 + continue + if ch in ("'", '"'): + quote = ch + i += 1 + while i < len(src) and src[i] != quote: + i += 2 if src[i] == "\\" else 1 + i += 1 + continue + if ch == "`": + i += 1 + while i < len(src) and src[i] != "`": + i += 2 if src[i] == "\\" else 1 + i += 1 + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[body_start:i] + i += 1 + raise AssertionError(f"locale {locale!r} block never closed") + + +def _string_value(block: str, key: str) -> str: + match = re.search(rf"^\s+{re.escape(key)}:\s+'([^']*)',(?P[^\n]*)$", block, re.M) + assert match, f"{key} missing" + assert "TODO: translate" not in match.group("tail") + return match.group(1) + + +def test_logs_severity_filter_keys_are_translated_for_non_english_locales(): + for locale, expected in LOGS_FILTER_KEYS.items(): + block = _i18n_locale_block(locale) + for key, value in expected.items(): + assert _string_value(block, key) == value From 02ca306ffc85c134e52f047c384c9bc0c16ef614 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 11:43:42 +0800 Subject: [PATCH 09/15] Consolidate session post-render processing --- CHANGELOG.md | 2 + static/sessions.js | 3 -- static/ui.js | 59 ++++++++++++++-------- tests/test_csv_table_rendering.py | 18 ++++--- tests/test_excalidraw_inline_embed.py | 20 +++++--- tests/test_issue347.py | 21 ++++---- tests/test_issue483_inline_diff_viewer.py | 11 ++-- tests/test_issue484_json_tree_viewer.py | 8 +-- tests/test_parallel_session_switch.py | 26 ++++------ tests/test_pdf_html_preview.py | 61 ++++++++++++----------- 10 files changed, 127 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d737b24..7e185cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **PR #2166** by @franksong2702 — Session switches no longer repeat the transcript post-render pass multiple times. The message renderer now schedules one `postProcessRenderedMessages(inner)` pass for both cached and freshly rebuilt transcripts, scopes inline preview/math/tree/code helpers to the rendered message container, and removes the extra idle-session `highlightCode()` call after `renderMessages()`. Local 8787 measurement on three large real sessions reduced post-render helper calls from `highlightCode=3x` / PDF+HTML+Mermaid+KaTeX `2x` to `1x` each, with static regression coverage pinning the consolidated pass. + - **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior. ### Added diff --git a/static/sessions.js b/static/sessions.js index 17ee262c..341d53fb 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -654,10 +654,7 @@ async function loadSession(sid){ updateQueueBadge(sid); syncTopbar();renderMessages(); if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid); - // Kick off loadDir first (issues network requests), then highlight code. - // The fetch is dispatched before the CPU-bound Prism pass begins. const _dirP=loadDir('.'); - highlightCode(); await _dirP; } } diff --git a/static/ui.js b/static/ui.js index d071d93e..2e9a7da5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4851,8 +4851,7 @@ function renderMessages(options){ _wireMessageWindowLoadEarlierButton(); if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs(); _scrollAfterMessageRender(preserveScroll, scrollSnapshot); - requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); - requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); + requestAnimationFrame(()=>postProcessRenderedMessages(inner)); if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver(); if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();} return; @@ -5409,8 +5408,7 @@ function renderMessages(options){ // scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up. _scrollAfterMessageRender(preserveScroll, scrollSnapshot); // Apply syntax highlighting after DOM is built - requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); - requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); + requestAnimationFrame(()=>postProcessRenderedMessages(inner)); // Refresh todo panel if it's currently open if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){ loadTodos(); @@ -5721,6 +5719,19 @@ async function regenerateResponse(btn) { } catch(e) { setStatus(t('regen_failed') + e.message); } } +function postProcessRenderedMessages(container) { + highlightCode(container); + addCopyButtons(container); + loadDiffInline(container); + loadCsvInline(container); + loadExcalidrawInline(container); + loadPdfInline(container); + loadHtmlInline(container); + renderMermaidBlocks(container); + renderKatexBlocks(container); + initTreeViews(container); +} + function highlightCode(container) { // Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area) if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return; @@ -5744,8 +5755,9 @@ function _loadJsyamlThen(cb){ document.head.appendChild(s); } -function initTreeViews(){ - document.querySelectorAll('.code-tree-wrap:not([data-tree-init])').forEach(wrap=>{ +function initTreeViews(container){ + const root=container||document; + root.querySelectorAll('.code-tree-wrap:not([data-tree-init])').forEach(wrap=>{ const rawText=wrap.dataset.raw; const lang=wrap.dataset.lang; let parsed=null; @@ -5902,9 +5914,10 @@ function addCopyButtons(container){ let _mermaidLoading=false; let _mermaidReady=false; -function loadDiffInline(){ +function loadDiffInline(container){ const DIFF_MAX_SIZE=512*1024; // 512 KB cap for inline diff rendering - document.querySelectorAll('.diff-inline-load:not([data-loaded])').forEach(el=>{ + const root=container||document; + root.querySelectorAll('.diff-inline-load:not([data-loaded])').forEach(el=>{ el.setAttribute('data-loaded','1'); const path=el.dataset.path; fetch('api/media?path='+encodeURIComponent(path)) @@ -5929,9 +5942,10 @@ function loadDiffInline(){ }); } -function loadCsvInline(){ +function loadCsvInline(container){ const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering - document.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{ + const root=container||document; + root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{ el.setAttribute('data-loaded','1'); const path=el.dataset.path; fetch('api/media?path='+encodeURIComponent(path)) @@ -5964,9 +5978,10 @@ function loadCsvInline(){ }); } -function loadExcalidrawInline(){ +function loadExcalidrawInline(container){ const EXCALIDRAW_MAX_SIZE=512*1024; // 512 KB cap - document.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{ + const root=container||document; + root.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{ el.setAttribute('data-loaded','1'); const path=el.dataset.path; fetch('api/media?path='+encodeURIComponent(path)) @@ -6090,9 +6105,10 @@ function _renderExcalidrawCanvases(){ // the full buffer is received — ideally the server would enforce it before // streaming (out of scope for this client-side PR). let _pdfjsReady=false, _pdfjsLoading=false; -function loadPdfInline(){ +function loadPdfInline(container){ const PDF_MAX_SIZE=4*1024*1024; // 4 MB cap for inline PDF preview - document.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{ + const root=container||document; + root.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{ el.setAttribute('data-loaded','1'); const path=el.dataset.path; const fname=path.split('/').pop()||path; @@ -6164,9 +6180,10 @@ function loadPdfInline(){ } // ── HTML inline preview (sandboxed iframe) ───────────────────────────────── -function loadHtmlInline(){ +function loadHtmlInline(container){ const HTML_MAX_SIZE=256*1024; // 256 KB cap for inline HTML preview - document.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{ + const root=container||document; + root.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{ el.setAttribute('data-loaded','1'); const path=el.dataset.path; const fname=path.split('/').pop()||path; @@ -6189,8 +6206,9 @@ function loadHtmlInline(){ }); } -function renderMermaidBlocks(){ - const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])'); +function renderMermaidBlocks(container){ + const root=container||document; + const blocks=root.querySelectorAll('.mermaid-block:not([data-rendered])'); if(!blocks.length) return; if(!_mermaidReady){ if(!_mermaidLoading){ @@ -6239,8 +6257,9 @@ function renderMermaidBlocks(){ let _katexLoading=false; let _katexReady=false; -function renderKatexBlocks(){ - const blocks=document.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])'); +function renderKatexBlocks(container){ + const root=container||document; + const blocks=root.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])'); if(!blocks.length) return; if(!_katexReady){ if(!_katexLoading){ diff --git a/tests/test_csv_table_rendering.py b/tests/test_csv_table_rendering.py index a9c125ad..1d208c9f 100644 --- a/tests/test_csv_table_rendering.py +++ b/tests/test_csv_table_rendering.py @@ -53,14 +53,14 @@ def test_loadCsvInline_function(): """Verify loadCsvInline lazy-load function exists.""" with open('static/ui.js') as f: src = f.read() - assert 'function loadCsvInline()' in src, "Missing loadCsvInline function" + assert 'function loadCsvInline' in src, "Missing loadCsvInline function" def test_csv_inline_max_size(): """Verify CSV inline rendering has a size cap.""" with open('static/ui.js') as f: src = f.read() - csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000] + csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000] assert 'CSV_MAX_SIZE' in csv_section, "Should have CSV_MAX_SIZE constant" assert 'csv_too_large' in csv_section, "Should use csv_too_large i18n for oversized files" @@ -69,7 +69,7 @@ def test_csv_auto_detect_separator(): """Verify CSV handler auto-detects separator.""" with open('static/ui.js') as f: src = f.read() - csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000] + csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000] assert 'separators' in csv_section, "Should have separator detection" assert ';' in csv_section, "Should detect semicolon separator" assert 'tab' in csv_section.lower() or '\\t' in csv_section, "Should detect tab separator" @@ -86,24 +86,26 @@ def test_csv_error_handling(): """Verify CSV error and empty data handling.""" with open('static/ui.js') as f: src = f.read() - csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2500] + csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2500] assert 'csv_error' in csv_section, "Should use csv_error i18n on fetch failure" assert 'csv_no_data' in csv_section, "Should use csv_no_data i18n for insufficient data" def test_csv_loadCsvInline_called_after_render(): - """Verify loadCsvInline is called in requestAnimationFrame after rendering.""" + """Verify loadCsvInline is called by the consolidated post-render pass.""" with open('static/ui.js') as f: src = f.read() - assert src.count('loadCsvInline()') >= 2, \ - "loadCsvInline should be called at least twice (initial render + cache restore)" + assert 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' in src + idx = src.find('function postProcessRenderedMessages') + body = src[idx:idx + 500] + assert 'loadCsvInline(container)' in body, "post-process should call loadCsvInline once per render" def test_csv_line_ending_normalization(): """Verify CSV handler normalizes line endings.""" with open('static/ui.js') as f: src = f.read() - csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000] + csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000] assert '\\r\\n' in csv_section, "Should handle \\r\\n line endings" assert '\\r' in csv_section, "Should handle \\r line endings" diff --git a/tests/test_excalidraw_inline_embed.py b/tests/test_excalidraw_inline_embed.py index c407e108..677aac07 100644 --- a/tests/test_excalidraw_inline_embed.py +++ b/tests/test_excalidraw_inline_embed.py @@ -22,14 +22,14 @@ def test_loadExcalidrawInline_function(): """Verify loadExcalidrawInline lazy-load function exists.""" with open('static/ui.js') as f: src = f.read() - assert 'function loadExcalidrawInline()' in src, "Missing loadExcalidrawInline function" + assert 'function loadExcalidrawInline' in src, "Missing loadExcalidrawInline function" def test_excalidraw_json_validation(): """Verify Excalidraw handler validates JSON format.""" with open('static/ui.js') as f: src = f.read() - func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000] + func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 2000] assert 'JSON.parse' in func, "Should parse JSON" assert 'excalidraw_invalid' in func, "Should handle invalid format" assert "data.type!=='excalidraw'" in func, "Should validate type field is 'excalidraw'" @@ -39,7 +39,7 @@ def test_excalidraw_size_cap(): """Verify Excalidraw inline rendering has a size cap.""" with open('static/ui.js') as f: src = f.read() - func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000] + func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 2000] assert 'EXCALIDRAW_MAX_SIZE' in func, "Should have EXCALIDRAW_MAX_SIZE constant" assert 'excalidraw_too_large' in func, "Should use excalidraw_too_large i18n for oversized files" @@ -48,7 +48,7 @@ def test_excalidraw_error_handling(): """Verify Excalidraw error handling.""" with open('static/ui.js') as f: src = f.read() - func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 3500] + func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 3500] assert 'excalidraw_error' in func, "Should use excalidraw_error i18n on fetch failure" @@ -114,17 +114,21 @@ def test_excalidraw_download_link(): """Verify Excalidraw embed includes download link.""" with open('static/ui.js') as f: src = f.read() - func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000] + func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 2000] assert 'excalidraw-open-link' in func, "Should include open/download link" assert 'excalidraw_download' in func, "Should use excalidraw_download i18n" def test_excalidraw_called_after_render(): - """Verify loadExcalidrawInline is called after message rendering.""" + """Verify loadExcalidrawInline is called by the consolidated post-render pass.""" with open('static/ui.js') as f: src = f.read() - assert src.count('loadExcalidrawInline()') >= 2, \ - "loadExcalidrawInline should be called at least twice" + assert 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' in src + idx = src.find('function postProcessRenderedMessages') + body = src[idx:idx + 500] + assert 'loadExcalidrawInline(container)' in body, ( + "post-process should call loadExcalidrawInline once per render" + ) def test_excalidraw_embed_wrap_structure(): diff --git a/tests/test_issue347.py b/tests/test_issue347.py index 4513139d..d51d8002 100644 --- a/tests/test_issue347.py +++ b/tests/test_issue347.py @@ -166,7 +166,7 @@ def test_data_katex_attribute_present(): def test_render_katex_blocks_function_exists(): """renderKatexBlocks() function must exist in ui.js.""" - assert 'function renderKatexBlocks()' in UI_JS, \ + assert 'function renderKatexBlocks' in UI_JS, \ 'renderKatexBlocks() function not found in ui.js' @@ -202,21 +202,18 @@ def test_katex_throw_on_error_false(): def test_render_katex_blocks_wired_into_raf(): - """renderKatexBlocks() must be called in the same requestAnimationFrame as renderMermaidBlocks().""" - # Check that renderKatexBlocks appears somewhere near requestAnimationFrame - raf_idx = UI_JS.find('requestAnimationFrame') - # Find the rAF call that also contains renderKatexBlocks - has_katex_in_raf = any( - 'renderKatexBlocks' in UI_JS[m.start():m.start()+200] - for m in re.finditer(r'requestAnimationFrame', UI_JS) - ) - assert has_katex_in_raf, \ - 'renderKatexBlocks() not found in any requestAnimationFrame call — math will not render' + """renderKatexBlocks() must run from the post-render requestAnimationFrame pass.""" + raf_call = 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' + assert raf_call in UI_JS, 'post-render requestAnimationFrame pass not found' + idx = UI_JS.find('function postProcessRenderedMessages') + body = UI_JS[idx:idx + 500] + assert 'renderMermaidBlocks(container)' in body + assert 'renderKatexBlocks(container)' in body def test_mermaid_render_failure_removes_temporary_error_dom(): """Failed Mermaid renders must not leave Mermaid's body-level syntax-error SVG visible.""" - fn_start = UI_JS.find('function renderMermaidBlocks()') + fn_start = UI_JS.find('function renderMermaidBlocks') assert fn_start != -1, 'renderMermaidBlocks() function not found in ui.js' fn = UI_JS[fn_start:fn_start + 2200] cleanup = "const tmp=document.getElementById('d'+id);\n if(tmp) tmp.remove();" diff --git a/tests/test_issue483_inline_diff_viewer.py b/tests/test_issue483_inline_diff_viewer.py index 2fd8573c..215d20ce 100644 --- a/tests/test_issue483_inline_diff_viewer.py +++ b/tests/test_issue483_inline_diff_viewer.py @@ -56,14 +56,17 @@ class TestMediaDiffInline: """loadDiffInline() function should be defined.""" with open("static/ui.js", "r", encoding="utf-8") as f: content = f.read() - assert "function loadDiffInline()" in content + assert "function loadDiffInline" in content def test_loadDiffInline_called_in_post_render(self): - """loadDiffInline() should be called in post-render (after addCopyButtons).""" + """loadDiffInline() should be called by the consolidated post-render pass.""" with open("static/ui.js", "r", encoding="utf-8") as f: content = f.read() - count = content.count("loadDiffInline()") - assert count >= 2, f"loadDiffInline() called {count} times, expected >= 2 (cached + fresh render)" + assert "requestAnimationFrame(()=>postProcessRenderedMessages(inner))" in content + start = content.find("function postProcessRenderedMessages") + body = content[start:start + 500] + assert "addCopyButtons(container)" in body + assert "loadDiffInline(container)" in body def test_diff_inline_error_class(self): """Should have error state class.""" diff --git a/tests/test_issue484_json_tree_viewer.py b/tests/test_issue484_json_tree_viewer.py index 37007f51..789c8d17 100644 --- a/tests/test_issue484_json_tree_viewer.py +++ b/tests/test_issue484_json_tree_viewer.py @@ -20,7 +20,7 @@ class TestTreeRenderer: def test_initTreeViews_function_exists(self): with open("static/ui.js", "r", encoding="utf-8") as f: content = f.read() - assert "function initTreeViews()" in content + assert "function initTreeViews" in content def test_buildTreeDOM_function_exists(self): with open("static/ui.js", "r", encoding="utf-8") as f: @@ -30,8 +30,10 @@ class TestTreeRenderer: def test_initTreeViews_called_in_post_render(self): with open("static/ui.js", "r", encoding="utf-8") as f: content = f.read() - count = content.count("initTreeViews()") - assert count >= 2, f"initTreeViews() called {count} times, expected >= 2" + assert "requestAnimationFrame(()=>postProcessRenderedMessages(inner))" in content + start = content.find("function postProcessRenderedMessages") + body = content[start:start + 500] + assert "initTreeViews(container)" in body def test_tree_handles_all_value_types(self): """_buildTreeDOM should handle null, boolean, number, string, array, object.""" diff --git a/tests/test_parallel_session_switch.py b/tests/test_parallel_session_switch.py index 566bd19d..75496ec8 100644 --- a/tests/test_parallel_session_switch.py +++ b/tests/test_parallel_session_switch.py @@ -59,15 +59,14 @@ class TestLoadDirParallelPrefetch: ) -# ── 2. sessions.js: loadSession idle path overlaps loadDir and highlightCode ─ +# ── 2. sessions.js: loadSession idle path avoids duplicate highlighting ─ class TestLoadSessionIdleOverlap: - """The idle path in loadSession() must start loadDir() before running - highlightCode() so the network request is in-flight during the CPU-bound - Prism.js pass.""" + """The idle path in loadSession() should rely on renderMessages() for the + post-render transcript pass instead of running another Prism.js pass.""" - def test_idle_path_starts_loaddir_before_highlightcode(self): + def test_idle_path_does_not_repeat_highlight_after_render_messages(self): idle_marker = "S.busy=false" positions = [] start = 0 @@ -81,25 +80,22 @@ class TestLoadSessionIdleOverlap: found = False for pos in positions: block = SESSIONS_JS[pos : pos + 600] - has_highlight = "highlightCode()" in block has_loaddir = "loadDir('.')" in block - if has_highlight and has_loaddir: + has_render = "renderMessages()" in block + if has_loaddir and has_render: found = True - loaddir_idx = block.find("loadDir(") - highlight_idx = block.find("highlightCode()") - assert loaddir_idx < highlight_idx, ( - "In the idle path, loadDir() should be started before " - "highlightCode() so the network request is dispatched first." + assert "highlightCode()" not in block, ( + "The idle path should rely on renderMessages()'s consolidated " + "post-render pass instead of running a second highlight pass." ) assert "await" in block and "_dirP" in block, ( - "loadDir() result should be stored and awaited after " - "highlightCode() completes." + "loadDir() result should still be stored and awaited." ) break assert found, ( "Could not find the idle path in loadSession that calls both " - "loadDir and highlightCode." + "renderMessages and loadDir." ) diff --git a/tests/test_pdf_html_preview.py b/tests/test_pdf_html_preview.py index 7b97bf60..cb174b27 100644 --- a/tests/test_pdf_html_preview.py +++ b/tests/test_pdf_html_preview.py @@ -104,37 +104,37 @@ class TestLoadPdfInlineFunction: def test_function_exists(self): ui = _read_js('ui.js') - assert 'function loadPdfInline()' in ui, 'loadPdfInline() function must exist' + assert 'function loadPdfInline' in ui, 'loadPdfInline() function must exist' def test_selects_pdf_preview_load_elements(self): ui = _read_js('ui.js') - idx = ui.find('function loadPdfInline()') + idx = ui.find('function loadPdfInline') body = ui[idx:idx + 500] assert 'pdf-preview-load' in body, 'Must query .pdf-preview-load elements' assert 'data-loaded' in body, 'Must use data-loaded attribute to prevent double-processing' def test_fetches_via_api_media(self): ui = _read_js('ui.js') - idx = ui.find('function loadPdfInline()') + idx = ui.find('function loadPdfInline') body = ui[idx:idx + 1500] assert 'api/media?path=' in body, 'Must fetch PDF via api/media endpoint' def test_has_size_cap(self): ui = _read_js('ui.js') - idx = ui.find('function loadPdfInline()') + idx = ui.find('function loadPdfInline') body = ui[idx:idx + 1500] assert 'MAX_SIZE' in body or 'byteLength' in body, 'Must enforce a size cap on PDF files' def test_fallback_on_error(self): ui = _read_js('ui.js') - idx = ui.find('function loadPdfInline()') + idx = ui.find('function loadPdfInline') body = ui[idx:idx + 3000] assert 'pdf_error' in body, 'Must show error fallback on failure' assert 'pdf_download' in body or 'download=' in body, 'Error fallback must include download link' def test_lazy_loads_pdfjs_from_cdn(self): ui = _read_js('ui.js') - idx = ui.find('function loadPdfInline()') + idx = ui.find('function loadPdfInline') body = ui[idx:idx + 3000] assert 'pdfjs' in body, 'Must lazy-load PDF.js from CDN' @@ -149,44 +149,44 @@ class TestLoadHtmlInlineFunction: def test_function_exists(self): ui = _read_js('ui.js') - assert 'function loadHtmlInline()' in ui, 'loadHtmlInline() function must exist' + assert 'function loadHtmlInline' in ui, 'loadHtmlInline() function must exist' def test_selects_html_preview_load_elements(self): ui = _read_js('ui.js') - idx = ui.find('function loadHtmlInline()') + idx = ui.find('function loadHtmlInline') body = ui[idx:idx + 500] assert 'html-preview-load' in body, 'Must query .html-preview-load elements' assert 'data-loaded' in body, 'Must use data-loaded attribute' def test_fetches_via_api_media(self): ui = _read_js('ui.js') - idx = ui.find('function loadHtmlInline()') + idx = ui.find('function loadHtmlInline') body = ui[idx:idx + 1000] assert 'api/media?path=' in body, 'Must fetch HTML via api/media endpoint' def test_has_size_cap(self): ui = _read_js('ui.js') - idx = ui.find('function loadHtmlInline()') + idx = ui.find('function loadHtmlInline') body = ui[idx:idx + 1000] assert 'MAX_SIZE' in body or 'html.length' in body, 'Must enforce a size cap on HTML files' def test_fallback_on_error(self): ui = _read_js('ui.js') - idx = ui.find('function loadHtmlInline()') + idx = ui.find('function loadHtmlInline') body = ui[idx:idx + 2000] assert 'html_error' in body, 'Must show error fallback on failure' def test_uses_srcdoc_attribute(self): """Must use srcdoc (not src) for HTML content to keep it same-origin sandboxed.""" ui = _read_js('ui.js') - idx = ui.find('function loadHtmlInline()') + idx = ui.find('function loadHtmlInline') body = ui[idx:idx + 1500] assert 'srcdoc=' in body, 'Must use srcdoc attribute for inline HTML rendering' def test_escapes_html_for_srcdoc(self): """HTML content must be escaped before embedding in srcdoc to prevent attribute injection.""" ui = _read_js('ui.js') - idx = ui.find('function loadHtmlInline()') + idx = ui.find('function loadHtmlInline') body = ui[idx:idx + 1500] # Must escape &, <, >, " to prevent breaking out of srcdoc attribute assert '&' in body or 'replace' in body, 'Must escape HTML entities for srcdoc' @@ -195,31 +195,34 @@ class TestLoadHtmlInlineFunction: # ── requestAnimationFrame integration ────────────────────────────────────── class TestRAFIntegration: - """Both lazy-load functions must be called in the requestAnimationFrame blocks.""" + """Lazy-load functions must be called by the consolidated post-render pass.""" def test_loadPdfInline_called_after_render(self): ui = _read_js('ui.js') - raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui) - load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b] - assert len(load_blocks) >= 2, 'Expected at least 2 rAF blocks with loadDiffInline' - for block in load_blocks: - assert 'loadPdfInline()' in block, 'loadPdfInline() must be called alongside loadDiffInline' + idx = ui.find('function postProcessRenderedMessages') + body = ui[idx:idx + 500] + assert 'loadDiffInline(container)' in body, 'post-process must call loadDiffInline' + assert 'loadPdfInline(container)' in body, 'post-process must call loadPdfInline alongside loadDiffInline' def test_loadHtmlInline_called_after_render(self): ui = _read_js('ui.js') - raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui) - load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b] - for block in load_blocks: - assert 'loadHtmlInline()' in block, 'loadHtmlInline() must be called alongside loadDiffInline' + idx = ui.find('function postProcessRenderedMessages') + body = ui[idx:idx + 500] + assert 'loadDiffInline(container)' in body, 'post-process must call loadDiffInline' + assert 'loadHtmlInline(container)' in body, 'post-process must call loadHtmlInline alongside loadDiffInline' def test_initTreeViews_blocks_also_call_loaders(self): - """rAF blocks with initTreeViews (not loadDiffInline) must also call PDF/HTML loaders.""" + """Tree views and inline loaders must share the same post-process pass.""" ui = _read_js('ui.js') - raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui) - tree_blocks = [b for b in raf_blocks if 'initTreeViews' in b and 'loadDiffInline' not in b] - for block in tree_blocks: - assert 'loadPdfInline()' in block, 'initTreeViews rAF block must also call loadPdfInline' - assert 'loadHtmlInline()' in block, 'initTreeViews rAF block must also call loadHtmlInline' + idx = ui.find('function postProcessRenderedMessages') + body = ui[idx:idx + 500] + assert 'initTreeViews(container)' in body, 'post-process must initialize tree views' + assert 'loadPdfInline(container)' in body, 'post-process must also call loadPdfInline' + assert 'loadHtmlInline(container)' in body, 'post-process must also call loadHtmlInline' + + def test_message_render_uses_single_post_process_raf(self): + ui = _read_js('ui.js') + assert ui.count('requestAnimationFrame(()=>postProcessRenderedMessages(inner))') == 2 # ── CSS classes ──────────────────────────────────────────────────────────── From e78945e7ca114e2ee00975d89a022a387971058a Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 12:35:12 +0800 Subject: [PATCH 10/15] Skip CLI metadata lookup for native sessions --- CHANGELOG.md | 2 + api/routes.py | 14 ++- tests/test_session_metadata_cli_lookup.py | 104 ++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/test_session_metadata_cli_lookup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d737b24..a5256c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- Native WebUI session metadata loads no longer scan Agent/CLI session metadata on every `/api/session` request. Imported CLI and messaging-backed sessions still keep the Agent metadata merge path, but ordinary WebUI session switches skip the expensive state.db query. + - **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior. ### Added diff --git a/api/routes.py b/api/routes.py index 65f12285..8ca278a8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1430,6 +1430,18 @@ def _lookup_cli_session_metadata(session_id: str) -> dict: return {} +def _needs_cli_session_metadata(session) -> bool: + """Return true when /api/session should pay for Agent/CLI metadata lookup.""" + if not session: + return False + is_cli = ( + bool(session.get("is_cli_session")) + if isinstance(session, dict) + else bool(getattr(session, "is_cli_session", False)) + ) + return is_cli or _is_messaging_session_record(session) + + def _messaging_session_identity(session: dict, raw_source: str) -> str: metadata = _lookup_gateway_session_identity(session.get("session_id")) session_key = _safe_first( @@ -3125,7 +3137,7 @@ def handle_get(handler, parsed) -> bool: _t1 = _time.monotonic() s = get_session(sid, metadata_only=(not load_messages)) _clear_stale_stream_state(s) - cli_meta = _lookup_cli_session_metadata(sid) + cli_meta = _lookup_cli_session_metadata(sid) if _needs_cli_session_metadata(s) else {} is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta) cli_messages = [] if is_messaging_session: diff --git a/tests/test_session_metadata_cli_lookup.py b/tests/test_session_metadata_cli_lookup.py new file mode 100644 index 00000000..60b2a4cc --- /dev/null +++ b/tests/test_session_metadata_cli_lookup.py @@ -0,0 +1,104 @@ +from types import SimpleNamespace +from unittest.mock import patch +from urllib.parse import urlparse + + +class _FakeSession: + def __init__(self, *, is_cli_session=False, session_source=None, source_tag=None): + self.session_id = "native_webui_001" + self.title = "Native WebUI" + self.workspace = "/tmp" + self.model = "gpt-test" + self.model_provider = None + self.messages = [] + self.tool_calls = [] + self.input_tokens = 0 + self.output_tokens = 0 + self.estimated_cost = 0 + self.context_length = 1 + self.threshold_tokens = 0 + self.last_prompt_tokens = 0 + self.active_stream_id = None + self.pending_user_message = None + self.pending_attachments = [] + self.pending_started_at = None + self.composer_draft = {} + self.is_cli_session = is_cli_session + self.session_source = session_source + self.source_tag = source_tag + self.raw_source = source_tag + self.source_label = source_tag + + def compact(self): + return { + "session_id": self.session_id, + "title": self.title, + "workspace": self.workspace, + "model": self.model, + "model_provider": self.model_provider, + "message_count": 0, + "context_length": self.context_length, + "threshold_tokens": self.threshold_tokens, + "last_prompt_tokens": self.last_prompt_tokens, + "active_stream_id": self.active_stream_id, + "pending_user_message": self.pending_user_message, + "composer_draft": self.composer_draft, + "is_cli_session": self.is_cli_session, + "session_source": self.session_source, + "source_tag": self.source_tag, + "raw_source": self.raw_source, + "source_label": self.source_label, + } + + +def _invoke_api_session(session_obj, *, lookup_cli): + import api.routes as routes + + captured = {} + + def fake_j(_handler, data, status=200, extra_headers=None): + captured["data"] = data + captured["status"] = status + return data + + parsed = urlparse("/api/session?session_id=native_webui_001&messages=0&resolve_model=0") + with patch("api.routes.get_session", return_value=session_obj), \ + patch("api.routes._clear_stale_stream_state", return_value=False), \ + patch("api.routes._lookup_cli_session_metadata", side_effect=lookup_cli) as lookup, \ + patch("api.routes.j", side_effect=fake_j): + routes.handle_get(SimpleNamespace(), parsed) + return captured, lookup + + +def test_api_session_metadata_skips_cli_lookup_for_native_webui_session(): + """Native WebUI sessions should not scan Agent state.db on every metadata load.""" + session = _FakeSession() + + def fail_lookup(_sid): + raise AssertionError("native WebUI metadata should not query CLI sessions") + + captured, lookup = _invoke_api_session(session, lookup_cli=fail_lookup) + + assert captured["status"] == 200 + assert captured["data"]["session"]["session_id"] == "native_webui_001" + lookup.assert_not_called() + + +def test_api_session_metadata_keeps_cli_lookup_for_imported_cli_session(): + """Imported CLI/messaging sessions still need Agent metadata for overlap handling.""" + session = _FakeSession(is_cli_session=True, session_source="messaging", source_tag="telegram") + + captured, lookup = _invoke_api_session( + session, + lookup_cli=lambda sid: { + "session_id": sid, + "session_source": "messaging", + "source_tag": "telegram", + "raw_source": "telegram", + "source_label": "Telegram", + }, + ) + + assert captured["status"] == 200 + assert captured["data"]["session"]["source_tag"] == "telegram" + lookup.assert_called_once_with("native_webui_001") From d5dda03ec22e7e4a0f1c19718295e3d40e3c771d Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 13 May 2026 13:09:42 +0800 Subject: [PATCH 11/15] Fix ctl Python wrapper ownership --- CHANGELOG.md | 1 + ctl.sh | 10 ++++++---- tests/test_ctl_script.py | 15 +++++++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d737b24..1873c257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- `ctl.sh status` and `ctl.sh stop` now recognize daemons launched through a custom `HERMES_WEBUI_PYTHON` wrapper, keeping the Python 3.13 process-lifecycle regression tests stable without weakening stale-PID protection. - **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior. ### Added diff --git a/ctl.sh b/ctl.sh index 1f41e497..132a146b 100755 --- a/ctl.sh +++ b/ctl.sh @@ -129,11 +129,12 @@ _build_bootstrap_args() { } _write_state() { - local pid="$1" host="$2" port="$3" + local pid="$1" host="$2" port="$3" python_exe="${4:-}" local state_dir="${HERMES_WEBUI_STATE_DIR:-${DEFAULT_STATE_DIR}}" { printf 'PID=%q\n' "${pid}" printf 'REPO_ROOT=%q\n' "${REPO_ROOT}" + printf 'PYTHON_EXE=%q\n' "${python_exe}" printf 'HOST=%q\n' "${host}" printf 'PORT=%q\n' "${port}" printf 'LOG_FILE=%q\n' "${LOG_FILE}" @@ -168,14 +169,15 @@ _proc_args() { } _is_owned_webui_pid() { - local pid="$1" args state_repo="" + local pid="$1" args state_repo="" state_python="" [[ -f "${STATE_FILE}" ]] || return 1 _load_state_if_present state_repo="${REPO_ROOT:-}" + state_python="${PYTHON_EXE:-}" [[ "${state_repo}" == "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ]] || return 1 args="$(_proc_args "${pid}")" [[ -n "${args}" ]] || return 1 - [[ "${args}" == *"${state_repo}/bootstrap.py"* || "${args}" == *"${state_repo}/server.py"* || "${args}" == *"${state_repo}/start.sh"* ]] + [[ "${args}" == *"${state_repo}/bootstrap.py"* || "${args}" == *"${state_repo}/server.py"* || "${args}" == *"${state_repo}/start.sh"* || ( -n "${state_python}" && "${args}" == *"${state_python}"* ) ]] } _current_pid() { @@ -222,7 +224,7 @@ start_cmd() { pid=$! printf '%s\n' "${pid}" > "${PID_FILE}" - _write_state "${pid}" "${CTL_HOST}" "${CTL_PORT}" + _write_state "${pid}" "${CTL_HOST}" "${CTL_PORT}" "${python_exe}" sleep 0.15 if ! _is_alive "${pid}"; then echo "[ctl] Hermes WebUI failed to stay running. Log: ${LOG_FILE}" >&2 diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index ea5dc14b..95134fc9 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -75,6 +75,17 @@ def wait_for_pid_file(pid_file: Path, timeout: float = 3.0) -> int: raise AssertionError(f"PID file was not written: {pid_file}") +def wait_for_file_text(path: Path, timeout: float = 3.0) -> str: + deadline = time.time() + timeout + while time.time() < deadline: + if path.exists(): + text = path.read_text(encoding="utf-8") + if text: + return text + time.sleep(0.05) + raise AssertionError(f"File was not written: {path}") + + def assert_process_exits(pid: int, timeout: float = 3.0) -> None: deadline = time.time() + timeout while time.time() < deadline: @@ -110,7 +121,7 @@ def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs( try: assert pid > 1 assert log_file.exists() - fake_output = fake_log.read_text(encoding="utf-8") + fake_output = wait_for_file_text(fake_log) assert "bootstrap.py --no-browser --foreground" in fake_output assert "host=0.0.0.0 port=18991" in fake_output assert str(hermes_home / "webui") in fake_output @@ -154,7 +165,7 @@ def test_start_loads_dotenv_but_inline_overrides_win(tmp_path): assert result.returncode == 0, result.stderr + result.stdout pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid") try: - fake_output = fake_log.read_text(encoding="utf-8") + fake_output = wait_for_file_text(fake_log) assert "fake-python args:" in fake_output assert "host=0.0.0.0 port=18888" in fake_output finally: From 29f5dea8356f09283f8d1f09fa50c7c16e86ffc2 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Tue, 12 May 2026 22:41:41 -0700 Subject: [PATCH 12/15] Soften session lineage count badge --- static/i18n.js | 5 ++++- tests/test_session_lineage_collapse.py | 27 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/static/i18n.js b/static/i18n.js index 84643cab..b65a25e2 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -686,7 +686,10 @@ const LOCALES = { workspace_desc: 'Add and switch workspaces for your sessions.', session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`, session_meta_children: (n) => `${n} child${n === 1 ? '' : 'ren'}`, - session_meta_segments: (n) => `${n} segment${n === 1 ? '' : 's'}`, + // Softened label: avoids exposing the technical internal term + // 'segment' in the default visible badge. User-facing copy remains + // translatable for locales that prefer a different wording. (#2155) + session_meta_segments: (n) => `${n} prior turn${n === 1 ? '' : 's'}`, session_lineage_segment_untitled: 'Untitled segment', session_lineage_segment_open: 'Open lineage segment', new_profile: 'New profile', diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 0d14938a..0653da1a 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -400,3 +400,30 @@ def test_lineage_segment_locale_keys_are_defined_for_sidebar_locales(): locale_count = i18n.count("session_meta_messages:") for key in required: assert i18n.count(key) >= locale_count, f"{key} missing from one or more locale blocks" + +def test_session_meta_segments_softened_label_no_literal_segment_in_english(): + """Regression: the sidebar badge for compressed/lineage rows must not visibly + say 'X segments' by default — the technical internal term should be replaced + with softer user-facing copy (#2155). + + This verifies the English base locale's session_meta_segments key so that + t() fallback for untranslated locales also produces softened copy. + """ + import re + i18n_text = (REPO_ROOT / 'static' / 'i18n.js').read_text(encoding='utf-8') + # Locate the English base-locale block (first occurrence, before any _lang guard). + first_lang = i18n_text.index('_lang: \'en\'') + second_lang = i18n_text.index('_lang:', first_lang + 1) + english_slice = i18n_text[first_lang:second_lang] + assert 'session_meta_segments:' in english_slice, 'session_meta_segments missing from English locale' + # Capture only the arrow-function value (not the key name which also contains 'segment'). + match = re.search( + r"session_meta_segments:\s*(\(\w+\)\s*=>\s*[^,]+)", + english_slice, + ) + assert match, 'session_meta_segments value not found in English locale' + rendered = match.group(1) + assert 'segment' not in rendered, ( + f"session_meta_segments English value still contains the technical word 'segment': {rendered}. " + "Expected softened copy like 'prior turn(s)' instead. See #2155." + ) From a4417d11f9467dc8a21318f6989f6edc1bd7262d Mon Sep 17 00:00:00 2001 From: MrFant Date: Wed, 13 May 2026 13:49:40 +0800 Subject: [PATCH 13/15] fix: handle dict model entries in provider models list When a provider's 'models' config contains dicts (e.g. {"id": "x", "label": "y"}) instead of plain strings, _apply_provider_prefix() crashes with: AttributeError: 'dict' object has no attribute 'startswith' This happens because the list comprehension at line 3505 passes the raw dict as the model ID. The fix extracts 'id' and 'label' from dict entries while keeping string entries as-is. Fixes the /api/models and /api/onboarding/status 500 errors. --- api/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/config.py b/api/config.py index d44c1039..91e50e75 100644 --- a/api/config.py +++ b/api/config.py @@ -3500,7 +3500,9 @@ def get_available_models() -> dict: if isinstance(cfg_models, dict): raw_models = [{"id": k, "label": k} for k in cfg_models.keys()] elif isinstance(cfg_models, list): - raw_models = [{"id": k, "label": k} for k in cfg_models] + raw_models = [{"id": k["id"] if isinstance(k, dict) else k, + "label": k.get("label", k["id"]) if isinstance(k, dict) else k} + for k in cfg_models] if not raw_models: raw_models = _models_from_live_provider_ids( From 55047e19e7a7673ced473102d090fdf80105fcff Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 13 May 2026 06:57:22 +0000 Subject: [PATCH 14/15] =?UTF-8?q?docs:=20CHANGELOG=20stage-346=20=E2=80=94?= =?UTF-8?q?=20close=20v0.51.52,=20open=20Unreleased=20for=2010-PR=20contri?= =?UTF-8?q?butor=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a930d3..f6608ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,40 @@ ## [Unreleased] +### Added + +- **PR #2156** by @franksong2702 — Issue #2057 Slice 2: guarded worktree remove action. New `POST /api/session/worktree/remove` and `remove_worktree_for_session(session, *, force=False)` helper. Rejects removal when the worktree is locked by an active stream or terminal, when it has local changes, untracked files, or unpushed commits ahead of origin. Clean removal runs without `--force`; `--force` is only used when the backend is explicitly called with `force=True`. Adds explicit per-session UI in the sidebar action menu (i18n strings for 9 locales), confirm dialog with two screenshot artifacts in `docs/pr-media/2156/`, and a 335-line regression suite in `tests/test_worktree_remove.py` covering the five fail-closed cases plus the explicit `force=True` override. + +- **PR #2160** by @franksong2702 — CSP report collector endpoint (closes #2095). New unauthenticated `POST /api/csp-report` accepts both legacy `report-uri` JSON (`{"csp-report": ...}`) and modern `application/reports+json` array payloads, with per-client in-memory rate limiting; over-limit reports are dropped with a warning while still returning 204 to avoid browser retry amplification. Existing CSP report-only header now advertises the collector via `report-uri /api/csp-report; report-to csp-endpoint`, with a matching `Report-To` response header. 117-line regression suite covering headers, auth/CSRF carve-out, both payload shapes, and rate-limit behavior. + +### Fixed + +- **PR #2158** by @franksong2702 (closes #2154) — Extends the stale-stream writeback guard from PR #2136 to two additional sites Opus advisor flagged on stage-345 review: the outer exception path (`api/streaming.py:3989`) that materializes `pending_user_message` and appends an `_error_message`, and the self-heal retry success path (`api/streaming.py:3947`) that persists `_heal_result`. Both can run after `active_stream_id` has rotated to a newer stream — same corruption pattern PR #2136 fixed on the normal success path. Each new site now mirrors the canonical guard: `if not _stream_writeback_is_current(s, stream_id): logger.info("Skipping stale stream writeback at "); return`. Adds regression coverage that pins both guards before their respective persistence operations. + +- **PR #2159** by @franksong2702 (closes #2157) — `/api/sessions` no longer serializes stale `active_stream_id` / `pending_*` fields after a stream dies or the server restarts. Adds a bounded route-layer post-pass that only considers rows with `active_stream_id` set and `is_streaming` not true, loads candidates with `metadata_only=True`, and delegates cleanup to the existing safe `_clear_stale_stream_state()` helper (preserves the #1558 full-load safety path and per-session lock recheck). Re-reads `all_sessions()` after a cleanup so the JSON response matches the persisted session state. + +- **PR #2161** by @franksong2702 (closes #2098) — Localized 5 Logs severity-filter keys (`logs_severity_*`) for `ja`, `ru`, `es`, `de`, `zh`, `zh-Hant`, `pt`, `ko`. Removes the affected `// TODO: translate` placeholders and adds the missing Traditional Chinese entries for these five keys. Regression coverage verifies each target locale has the expected localized values and that these key lines no longer carry TODO placeholders. + +- **PR #2173** by @franksong2702 (closes #2172) — `ctl.sh status` and `ctl.sh stop` now correctly recognize daemons started through a custom `HERMES_WEBUI_PYTHON` wrapper. Persists the resolved Python executable in `webui.ctl.env` as `PYTHON_EXE`, and `_is_owned_webui_pid()` now recognizes the recorded wrapper path while preserving the existing repo-root state guard. Stabilizes the existing ctl tests by waiting for the fake-wrapper log before reading it. Fixes the Python 3.13 CI failure exposed by PR #2171's session-tail tests. + +- **PR #2175** by @Michaelyklam (refs #2155) — Softened the session-lineage count badge from `X segments` to `X prior turn(s)` in the English base locale. Existing lineage expand/collapse behavior and accessibility attributes unchanged. Focused regression test verifies the new English badge label and forbids the old "segments" wording. + +- **PR #2176** by @MrFant — `_apply_provider_prefix()` no longer crashes with `AttributeError: 'dict' object has no attribute 'startswith'` when a provider's `models` config contains dict entries (`{"id": "x", "label": "y"}`) instead of plain strings. Fix extracts `id` and `label` from dict entries while keeping string entries as-is. Resolves `/api/models` and `/api/onboarding/status` 500 errors for users with dict-shaped model lists. + +### Performance + +- **PR #2166** by @franksong2702 — Consolidated session post-render processing into a single `postProcessRenderedMessages(container)` pass instead of two overlapping passes after both cached and freshly-rebuilt message DOM (plus a third highlight pass during idle session-loads). Scopes inline preview, tree-view, Mermaid, KaTeX, and code/copy-button passes to one walk over the rendered container. Vanilla JS architecture preserved; no changes to the markdown renderer, session loading, or DOM diffing model. + +- **PR #2170** by @franksong2702 — `/api/session?messages=0&resolve_model=0` metadata loads no longer pay the `_lookup_cli_session_metadata()` Agent/CLI scan for native WebUI sessions. New `_needs_cli_session_metadata()` predicate keeps the Agent metadata merge path for imported CLI sessions, messaging-backed sessions, read-only sessions, and external-agent sessions, but skips it for ordinary WebUI-native sessions. Profiling on real production state showed this was the remaining hot path after PR #2166 removed the duplicate browser post-render work. + +## [v0.51.52] — 2026-05-12 — Release AB (stage-345 — 2-PR low-risk batch — stream-ownership guard + Refresh-usage button on provider quota card) + ### Fixed -- `ctl.sh status` and `ctl.sh stop` now recognize daemons launched through a custom `HERMES_WEBUI_PYTHON` wrapper, keeping the Python 3.13 process-lifecycle regression tests stable without weakening stale-PID protection. - **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior. ### Added -- `POST /api/csp-report` now collects browser CSP report-only violations and the report-only policy advertises both `report-uri` and `report-to` sinks, making CSP dry-runs visible outside individual browser devtools consoles (closes #2095). - **PR #2150** by @Jordan-SkyLF — "Refresh usage" button on the Provider quota card in Settings → Providers. Calls `/api/provider/quota?refresh=1&ts=` with `cache: 'no-store'` to bypass browser, service worker, and reverse-proxy caches that may have stamped a previous quota response, then re-renders just the quota card from the fresh response and shows a `Last checked ...` timestamp. Disabled `Refreshing…` state during the in-flight request; success toast on completion or failure toast if the refresh fails. Note: the `refresh=1` query param is a no-op at the server today (`get_provider_quota()` has no in-process cache layer), so the win is strictly browser-side cache-bust + the `no-store` fetch option. A future maintainer follow-up may add server-side TTL caching of OAuth account-limit fetches, at which point the `refresh=1` param becomes load-bearing on both sides. ## [v0.51.51] — 2026-05-12 — Release AA (stage-344 — 16-PR contributor batch — i18n + insights bucketing/mobile + manual-compress async + workspace recovery + iOS PWA scroll + Cloudflare login health + fr locale) From fe3f810b5630540f468c28024924aeea4881a6fb Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 13 May 2026 07:15:53 +0000 Subject: [PATCH 15/15] =?UTF-8?q?stage-346:=20apply=20Opus=20SHOULD-FIX=20?= =?UTF-8?q?(defense-in-depth)=20=E2=80=94=20scope=20/api/csp-report=20auth?= =?UTF-8?q?=20bypass=20to=20POST=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus advisor flagged that PR #2160's CSP-report auth carve-out covered all write methods on the path, not just POST. Currently harmless (PATCH/DELETE fall through to CSRF 403 or routing 404), but defense-in-depth — scope the bypass to its actual use case. CSP report regression suite (6 tests) still passes. --- CHANGELOG.md | 4 ++++ server.py | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6608ecf..22c34a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ - **PR #2170** by @franksong2702 — `/api/session?messages=0&resolve_model=0` metadata loads no longer pay the `_lookup_cli_session_metadata()` Agent/CLI scan for native WebUI sessions. New `_needs_cli_session_metadata()` predicate keeps the Agent metadata merge path for imported CLI sessions, messaging-backed sessions, read-only sessions, and external-agent sessions, but skips it for ordinary WebUI-native sessions. Profiling on real production state showed this was the remaining hot path after PR #2166 removed the duplicate browser post-render work. +### Stage-346 maintainer fixes + +- **`server.py` CSP-report auth carve-out scoped to POST only** — Opus SHOULD-FIX from stage-346 review on PR #2160. The original carve-out (`parsed.path != "/api/csp-report" and not check_auth`) bypassed auth for all write methods on the endpoint, not just the POST that browsers actually use for CSP violation reports. PATCH/DELETE to that path currently fall through to a 403 (CSRF check) or 404 (routing), so the broad bypass was harmless — but defense-in-depth says scope the carve-out to its actual use. New check: `parsed.path == "/api/csp-report" and self.command == "POST"`. ~6 LOC. CSP report regression suite (6 tests) still passes. + ## [v0.51.52] — 2026-05-12 — Release AB (stage-345 — 2-PR low-risk batch — stream-ownership guard + Refresh-usage button on provider quota card) ### Fixed diff --git a/server.py b/server.py index 25aa110e..c61d09d4 100644 --- a/server.py +++ b/server.py @@ -265,7 +265,15 @@ class Handler(BaseHTTPRequestHandler): set_request_profile(cookie_profile) try: parsed = urlparse(self.path) - if parsed.path != "/api/csp-report" and not check_auth(self, parsed): return + # Stage-346 Opus SHOULD-FIX defense-in-depth: scope the CSP-report + # auth carve-out to POST only. The endpoint is intentionally + # unauthenticated (browsers omit cookies on CSP reports), but the + # carve-out should not extend to PATCH/DELETE on that path even + # though they currently fail through CSRF/routing fallthrough. + _is_csp_report_post = ( + parsed.path == "/api/csp-report" and self.command == "POST" + ) + if not _is_csp_report_post and not check_auth(self, parsed): return result = route_func(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404)