From 6e1e9fafbee10f69041c40d8fe6ad4bd02b76571 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 12 May 2026 08:03:24 +0800 Subject: [PATCH 01/28] Add worktree status endpoint --- CHANGELOG.md | 2 + api/routes.py | 19 ++ api/worktrees.py | 188 ++++++++++++++++++++ tests/test_issue2057_worktree_status.py | 221 ++++++++++++++++++++++++ 4 files changed, 430 insertions(+) create mode 100644 tests/test_issue2057_worktree_status.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c413c123..f2132efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Read-only worktree status endpoint for the #2057 lifecycle tracker. `GET /api/session/worktree/status?session_id=...` now returns the session-owned worktree path, filesystem existence, dirty/untracked state, ahead/behind counts when an upstream is configured, and live stream/embedded-terminal lock flags without mutating git state. This is the non-destructive status surface Nathan requested before any future explicit remove-worktree action. + - **PR #2105** by @Michaelyklam — Hermes run adapter contract RFC at `docs/rfcs/hermes-run-adapter-contract.md` (refs #1925). 315-line spec/gap matrix that defines the event/control compatibility contract WebUI needs before browser-originated chat turns can be routed to Hermes-owned runtime execution. Documents the ownership boundary (Hermes Agent owns run creation, lifecycle, event ordering, replay, terminal state, approvals, clarify, cancellation; WebUI owns browser auth, transcript rendering, tool cards, approval/clarify widgets, workspace UX), the minimum `start_run`/`observe_run`/`get_run`/`cancel_run`/`queue_or_continue`/`respond_approval`/`respond_clarify` IPC surface, and a gap matrix mapping current `STREAMS`/`CANCEL_FLAGS`/`AGENT_INSTANCES`/callback queues to Hermes-owned targets with explicit "no private callback queue" / "no runtime surrogate" non-goals. First success criterion is restart/reattach (start a non-trivial run, restart hermes-webui, browser reconnects, replays from last cursor, cancels with Hermes-emitted terminal state) — not "basic chat streamed once." Status: Proposed. ### Fixed diff --git a/api/routes.py b/api/routes.py index 49b0d81c..efae299c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3053,6 +3053,25 @@ 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] + 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": import time as _time _t0 = _time.monotonic() diff --git a/api/worktrees.py b/api/worktrees.py index 330a4385..d792251b 100644 --- a/api/worktrees.py +++ b/api/worktrees.py @@ -13,6 +13,194 @@ import logging logger = logging.getLogger(__name__) +def _run_git(args: list[str], cwd: str | Path, timeout: float = 5) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], + cwd=str(cwd), + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + + +def _resolve_path(path: str | Path | None) -> Path | None: + if not path: + return None + try: + return Path(path).expanduser().resolve(strict=False) + except (OSError, RuntimeError): + return Path(path).expanduser() + + +def _worktree_list_cwd(worktree_path: Path, repo_root: str | Path | None) -> Path | None: + repo = _resolve_path(repo_root) + if repo and repo.is_dir(): + return repo + if worktree_path.is_dir(): + return worktree_path + return None + + +def _parse_worktree_list_porcelain(output: str) -> set[str]: + paths: set[str] = set() + for line in str(output or "").splitlines(): + if not line.startswith("worktree "): + continue + path = line[len("worktree "):].strip() + if not path: + continue + resolved = _resolve_path(path) + paths.add(str(resolved or Path(path).expanduser())) + return paths + + +def _worktree_listed(worktree_path: Path, repo_root: str | Path | None) -> bool: + """Return whether git currently lists the worktree. + + False is a safe fallback for probe failures, not definitive orphan proof. + Future cleanup UI must combine this with the rest of the status payload. + """ + cwd = _worktree_list_cwd(worktree_path, repo_root) + if cwd is None: + return False + try: + result = _run_git(["worktree", "list", "--porcelain"], cwd) + except (OSError, subprocess.TimeoutExpired): + return False + if result.returncode != 0: + return False + return str(worktree_path) in _parse_worktree_list_porcelain(result.stdout) + + +def _status_porcelain(worktree_path: Path) -> tuple[bool, int]: + try: + result = _run_git( + ["status", "--porcelain", "--untracked-files=normal"], + worktree_path, + ) + except (OSError, subprocess.TimeoutExpired): + return False, 0 + if result.returncode != 0: + return False, 0 + lines = [line for line in result.stdout.splitlines() if line] + return bool(lines), sum(1 for line in lines if line.startswith("??")) + + +def _ahead_behind(worktree_path: Path) -> dict: + payload = { + "ahead": 0, + "behind": 0, + "available": False, + "upstream": None, + } + try: + upstream = _run_git( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + worktree_path, + ) + except (OSError, subprocess.TimeoutExpired): + return payload + if upstream.returncode != 0: + return payload + upstream_ref = upstream.stdout.strip() + if not upstream_ref: + return payload + payload["upstream"] = upstream_ref + try: + counts = _run_git( + ["rev-list", "--left-right", "--count", "HEAD...@{u}"], + worktree_path, + ) + except (OSError, subprocess.TimeoutExpired): + return payload + if counts.returncode != 0: + return payload + parts = counts.stdout.strip().split() + if len(parts) != 2: + return payload + try: + payload["ahead"] = max(0, int(parts[0])) + payload["behind"] = max(0, int(parts[1])) + payload["available"] = True + except ValueError: + pass + return payload + + +def _locked_by_stream(session) -> bool: + stream_id = getattr(session, "active_stream_id", None) + if not stream_id: + return False + try: + from api.config import STREAMS, STREAMS_LOCK + + with STREAMS_LOCK: + return stream_id in STREAMS + except Exception: + return False + + +def _locked_by_terminal(session_id: str, worktree_path: Path) -> bool: + try: + from api.terminal import get_terminal + + term = get_terminal(session_id) + except Exception: + return False + if not term: + return False + try: + if not term.is_alive(): + return False + terminal_workspace = _resolve_path(getattr(term, "workspace", None)) + return terminal_workspace == worktree_path + except Exception: + return False + + +def worktree_status_for_session(session) -> dict: + """Return a read-only worktree status snapshot for a WebUI session.""" + 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") + + exists = worktree_path.is_dir() + status = { + "path": str(worktree_path), + "exists": bool(exists), + "dirty": False, + "untracked_count": 0, + "ahead_behind": { + "ahead": 0, + "behind": 0, + "available": False, + "upstream": None, + }, + "locked_by_stream": _locked_by_stream(session), + "locked_by_terminal": _locked_by_terminal( + getattr(session, "session_id", ""), + worktree_path, + ), + "listed": _worktree_listed( + worktree_path, + getattr(session, "worktree_repo_root", None), + ), + } + if not exists: + return status + + dirty, untracked_count = _status_porcelain(worktree_path) + status["dirty"] = dirty + status["untracked_count"] = untracked_count + status["ahead_behind"] = _ahead_behind(worktree_path) + return status + + def find_git_repo_root(workspace: str | Path) -> Path: """Return the enclosing git repo root for *workspace*. diff --git a/tests/test_issue2057_worktree_status.py b/tests/test_issue2057_worktree_status.py new file mode 100644 index 00000000..84559892 --- /dev/null +++ b/tests/test_issue2057_worktree_status.py @@ -0,0 +1,221 @@ +import subprocess +from types import SimpleNamespace +from urllib.parse import urlparse + +import pytest + +import api.models as models +from api.models import SESSIONS, Session + + +def _git(cwd, *args): + return subprocess.run( + ["git", *args], + cwd=cwd, + text=True, + capture_output=True, + check=True, + ) + + +@pytest.fixture(autouse=True) +def _isolate_sessions(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") + SESSIONS.clear() + yield session_dir + SESSIONS.clear() + + +@pytest.fixture +def git_worktree(tmp_path): + repo = tmp_path / "repo" + remote = tmp_path / "remote.git" + worktree = tmp_path / "hermes-status" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Hermes Test") + _git(repo, "branch", "-M", "main") + (repo / "README.md").write_text("hello\n", encoding="utf-8") + _git(repo, "add", "README.md") + _git(repo, "commit", "-m", "initial") + _git(remote.parent, "init", "--bare", remote.name) + _git(repo, "remote", "add", "origin", str(remote)) + _git(repo, "push", "-u", "origin", "main") + _git(repo, "worktree", "add", "-b", "hermes/status", str(worktree), "main") + _git(worktree, "push", "-u", "origin", "hermes/status") + return repo, worktree + + +def _session_for_worktree(repo, worktree, **kwargs): + return Session( + session_id=kwargs.pop("session_id", "wtstatus001"), + workspace=str(worktree), + worktree_path=str(worktree), + worktree_branch="hermes/status", + worktree_repo_root=str(repo), + worktree_created_at=123.0, + **kwargs, + ) + + +def test_worktree_status_reports_clean_existing_worktree(git_worktree): + from api.worktrees import worktree_status_for_session + + repo, worktree = git_worktree + status = worktree_status_for_session(_session_for_worktree(repo, worktree)) + + assert status["path"] == str(worktree.resolve()) + assert status["exists"] is True + assert status["listed"] is True + assert status["dirty"] is False + assert status["untracked_count"] == 0 + assert status["ahead_behind"]["available"] is True + assert status["ahead_behind"]["ahead"] == 0 + assert status["ahead_behind"]["behind"] == 0 + assert status["locked_by_stream"] is False + assert status["locked_by_terminal"] is False + + +def test_worktree_status_reports_dirty_untracked_and_ahead(git_worktree): + from api.worktrees import worktree_status_for_session + + repo, worktree = git_worktree + (worktree / "README.md").write_text("hello\nedited\n", encoding="utf-8") + (worktree / "scratch.txt").write_text("local-only\n", encoding="utf-8") + status = worktree_status_for_session(_session_for_worktree(repo, worktree)) + + assert status["dirty"] is True + assert status["untracked_count"] == 1 + assert status["ahead_behind"]["ahead"] == 0 + + _git(worktree, "add", "README.md") + _git(worktree, "commit", "-m", "local change") + status = worktree_status_for_session(_session_for_worktree(repo, worktree)) + + assert status["dirty"] is True + assert status["untracked_count"] == 1 + assert status["ahead_behind"]["available"] is True + assert status["ahead_behind"]["ahead"] == 1 + assert status["ahead_behind"]["behind"] == 0 + + +def test_worktree_status_handles_missing_path_without_git_mutation(tmp_path): + from api.worktrees import worktree_status_for_session + + missing = tmp_path / "missing-worktree" + status = worktree_status_for_session( + SimpleNamespace( + session_id="missing", + worktree_path=str(missing), + worktree_repo_root=str(tmp_path / "repo"), + active_stream_id=None, + ) + ) + + assert status["path"] == str(missing.resolve()) + assert status["exists"] is False + assert status["dirty"] is False + assert status["untracked_count"] == 0 + assert status["ahead_behind"]["ahead"] == 0 + assert status["ahead_behind"]["behind"] == 0 + + +def test_worktree_status_uses_live_stream_registry(git_worktree): + from api.config import STREAMS, STREAMS_LOCK + from api.worktrees import worktree_status_for_session + + repo, worktree = git_worktree + session = _session_for_worktree( + repo, + worktree, + active_stream_id="live-stream", + ) + + with STREAMS_LOCK: + STREAMS["live-stream"] = object() + try: + assert worktree_status_for_session(session)["locked_by_stream"] is True + finally: + with STREAMS_LOCK: + STREAMS.pop("live-stream", None) + + assert worktree_status_for_session(session)["locked_by_stream"] is False + + +def test_worktree_status_reports_live_terminal_lock(git_worktree, monkeypatch): + import api.terminal as terminal + from api.worktrees import worktree_status_for_session + + repo, worktree = git_worktree + + class FakeTerminal: + workspace = str(worktree.resolve()) + + def is_alive(self): + return True + + monkeypatch.setattr(terminal, "get_terminal", lambda session_id: FakeTerminal()) + + status = worktree_status_for_session(_session_for_worktree(repo, worktree)) + + assert status["locked_by_terminal"] is True + + +def test_worktree_status_endpoint_returns_session_owned_status(git_worktree, monkeypatch): + import api.routes as routes + + repo, worktree = git_worktree + session = _session_for_worktree(repo, worktree, session_id="route_wt") + session.save() + captured = {} + + monkeypatch.setattr( + routes, + "j", + lambda handler, payload, status=200, extra_headers=None: captured.update( + payload=payload, + status=status, + ) or True, + ) + + handled = routes.handle_get( + object(), + urlparse("/api/session/worktree/status?session_id=route_wt"), + ) + + assert handled is True + assert captured["status"] == 200 + assert captured["payload"]["status"]["path"] == str(worktree.resolve()) + assert captured["payload"]["status"]["exists"] is True + + +def test_worktree_status_endpoint_rejects_non_worktree_session(tmp_path, monkeypatch): + import api.routes as routes + + workspace = tmp_path / "workspace" + workspace.mkdir() + session = Session(session_id="plain", workspace=str(workspace)) + session.save() + captured = {} + + monkeypatch.setattr( + routes, + "bad", + lambda handler, message, status=400: captured.update( + message=message, + status=status, + ) or True, + ) + + handled = routes.handle_get( + object(), + urlparse("/api/session/worktree/status?session_id=plain"), + ) + + assert handled is True + assert captured["status"] == 400 + assert "not worktree-backed" in captured["message"] From 4e8899592d22de53b4edc07fdeaf2febdd9e75c7 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 12 May 2026 10:13:25 +0800 Subject: [PATCH 02/28] Prefer worktree retention responses in session UI --- CHANGELOG.md | 2 ++ static/sessions.js | 41 ++++++++++++++++------ tests/test_1466_sidebar_cancel_clarify.py | 11 +++--- tests/test_issue2057_worktree_ui_static.py | 18 ++++++++++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c413c123..ea060626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Session archive/delete success copy now prefers the backend `worktree_retained` response over cached session snapshots, so stale sidebar state cannot show worktree-retention reassurance after the server says no worktree was retained (refs #2111). + - **PR #2107** (self-built, closes #2083) — Title-generation budget-doubling retry loop on reasoning-only model responses. Reporter @darkopetrovic on LM Studio with Qwen3.6-35B-A3B (and the broader class: DeepSeek-R1, Kimi-K2, other Qwen3-thinking variants) saw GPU never going idle after each prompt — the chat turn finished cleanly but the auto-title generation request burned its 500-token budget on hidden `reasoning_content`, emitted `content=""` with `finish_reason=length`, got classified as `llm_length`, retried at 1024 tokens, returned the same shape, then iterated through `_title_prompts()`'s two prompts for ~3000 reasoning tokens per new chat. The agent-side `is_lmstudio` classifier in `run_agent.py:9468` misses `custom:` providers pointing at LM Studio, so the `reasoning_effort: "none"` adapter never fires for that route. WebUI-side belt-and-braces fix: (1) `_extract_title_response()` reorders the empty-response classification to check `reasoning_content` first regardless of `finish_reason` — reasoning presence is the diagnostic signal, not finish_reason; (2) `_title_retry_status()` drops `llm_empty_reasoning{,_aux}` from the retry set (length-without-reasoning still retries — legitimate budget-truncation case); (3) new `_title_should_skip_remaining_attempts()` short-circuits the prompt-iteration loop, both aux and agent routes break to `_fallback_title_from_exchange` for a local-summary title. Net: 4 calls → 1 call per chat. `tests/test_title_aux_routing.py` inverts the old reasoning-retry assertions and adds two new tests for the legitimate length-without-reasoning retry path. nesquena APPROVED with 200-line end-to-end trace + behavioral harness confirming the 4→1 call reduction. - **PR #2064** by @franksong2702 — Worktree session archive/delete confirm copy now reassures users that the underlying worktree directory remains on disk (refs #2057). Pre-fix the confirm dialogs said only "Delete this conversation?" / "Archive this conversation?" without clarifying that worktree-backed conversations preserve the worktree files even when the conversation row is removed — users were reasonably afraid of losing local work. Adds an explicit `worktree_retained` boolean on the `/api/session` payload that the frontend reads to surface "The worktree at /path will remain on disk." (single) and "N worktree-backed conversation(s) will keep their worktree directories on disk." (bulk) variants in both archive and delete dialogs. 81-line i18n update across all 9 locales (en/it/ja/ru/es/de/zh/pt/ko) with an English-bundle locale-leak fix caught during screenshot capture (several worktree strings had landed under Russian in error). Regression coverage in `tests/test_issue2057_worktree_lifecycle.py` + `tests/test_issue2057_worktree_ui_static.py`. UX-gate cleared with 5 viewports (4×1280px desktop covering single + bulk archive/delete confirms, 1×390px mobile of single-delete confirming dialog fits without overflow). diff --git a/static/sessions.js b/static/sessions.js index 35724911..89d7e792 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1290,11 +1290,20 @@ function _worktreeSessionCount(ids){ return count+(session&&session.worktree_path?1:0); },0); } +function _sessionResponseRetainsWorktree(response, session){ + if(response&&typeof response.worktree_retained==='boolean') return response.worktree_retained; + return !!(session&&session.worktree_path); +} +function _worktreeResponseCount(results){ + return (results||[]).reduce((count,result)=>{ + return count+(_sessionResponseRetainsWorktree(result&&result.response,result&&result.session)?1:0); + },0); +} function _sessionArchiveDescription(session){ return session&&session.worktree_path?t('session_archive_worktree_desc'):t('session_archive_desc'); } -function _sessionArchiveToast(session){ - return session&&session.worktree_path?t('session_archived_worktree'):t('session_archived'); +function _sessionArchiveToast(response, session){ + return _sessionResponseRetainsWorktree(response,session)?t('session_archived_worktree'):t('session_archived'); } function _sessionDeleteDescription(session){ return session&&session.worktree_path?t('session_delete_worktree_desc'):t('session_delete_desc'); @@ -1398,14 +1407,20 @@ function _renderBatchActionBar(){ archiveBtn.onclick=async()=>{ const ids=[..._selectedSessions]; const wtCount=_worktreeSessionCount(ids); + const sessionsById=new Map(ids.map(sid=>[sid,_sessionSnapshotById(sid)])); const ok=await showConfirmDialog({ message:wtCount?t('session_batch_archive_worktree_confirm',ids.length,wtCount):t('session_batch_archive_confirm',ids.length), confirmLabel:t('session_batch_archive'), danger:true }); if(!ok)return; - try{await Promise.all(ids.map(sid=>api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:sid,archived:true})}))); - showToast(wtCount?t('session_archived_worktree'):t('session_archived'));exitSessionSelectMode();await renderSessionList(); + try{ + const results=await Promise.all(ids.map(async sid=>{ + const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:sid,archived:true})}); + return {response,session:sessionsById.get(sid)||null}; + })); + const retainedCount=_worktreeResponseCount(results); + showToast(retainedCount?t('session_archived_worktree'):t('session_archived'));exitSessionSelectMode();await renderSessionList(); }catch(e){showToast('Archive failed: '+(e.message||e));} };bar.appendChild(archiveBtn); // Move @@ -1418,6 +1433,7 @@ function _renderBatchActionBar(){ deleteBtn.onclick=async()=>{ const ids=[..._selectedSessions]; const wtCount=_worktreeSessionCount(ids); + const sessionsById=new Map(ids.map(sid=>[sid,_sessionSnapshotById(sid)])); const ok=await showConfirmDialog({ message:wtCount?t('session_batch_delete_worktree_confirm',ids.length,wtCount):t('session_batch_delete_confirm',ids.length), confirmLabel:t('delete_title'), @@ -1425,7 +1441,11 @@ function _renderBatchActionBar(){ }); if(!ok)return; try{ - await Promise.all(ids.map(sid=>api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}))); + const results=await Promise.all(ids.map(async sid=>{ + const response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); + return {response,session:sessionsById.get(sid)||null}; + })); + const retainedCount=_worktreeResponseCount(results); ids.forEach(_clearHandoffStorageForSession); if(S.session&&ids.includes(S.session.session_id)){ S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session'); @@ -1433,7 +1453,7 @@ function _renderBatchActionBar(){ if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);} else{$('msgInner').innerHTML='';$('emptyState').style.display='';} } - showToast((wtCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList(); + showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList(); }catch(e){showToast('Delete failed: '+(e.message||e));} };bar.appendChild(deleteBtn); } @@ -1606,11 +1626,11 @@ function _openSessionActionMenu(session, anchorEl){ async()=>{ closeSessionActionMenu(); try{ - await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})}); + const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})}); session.archived=!session.archived; if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived; await renderSessionList(); - showToast(session.archived?_sessionArchiveToast(session):t('session_restored')); + showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); }catch(err){showToast(t('session_archive_failed')+err.message);} } )); @@ -3051,8 +3071,9 @@ async function deleteSession(sid){ danger:true }); if(!ok)return; + let response=null; try{ - await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); + response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); _clearHandoffStorageForSession(sid); }catch(e){setStatus(`Delete failed: ${e.message}`);return;} if(S.session&&S.session.session_id===sid){ @@ -3072,7 +3093,7 @@ async function deleteSession(sid){ if(typeof syncAppTitlebar==='function') syncAppTitlebar(); } } - showToast(session&&session.worktree_path?t('session_deleted_worktree'):t('session_deleted')); + showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted')); await renderSessionList(); } diff --git a/tests/test_1466_sidebar_cancel_clarify.py b/tests/test_1466_sidebar_cancel_clarify.py index 890c745b..cc87bbf2 100644 --- a/tests/test_1466_sidebar_cancel_clarify.py +++ b/tests/test_1466_sidebar_cancel_clarify.py @@ -22,11 +22,13 @@ class TestSidebarCancelAction: def test_running_sidebar_sessions_get_stop_action(self): """Running sessions need a context-menu cancel action even when not active pane.""" # Window bumped from 3200 → 4400 in #1764 to accommodate the new - # Rename action item that lands at the top of _openSessionActionMenu. + # Rename action item, then to 5200 in #2111 for response-aware archive + # toast handling inside _openSessionActionMenu before the stop/delete + # actions. # The `session.active_stream_id` / cancelSessionStream / delete checks # are positional further down in the function, so growing the prefix # required growing this read window. - body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4400) + body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200) assert "session.active_stream_id" in body, ( "sidebar action menu must detect per-session active_stream_id instead of S.activeStreamId" ) @@ -72,8 +74,9 @@ class TestSidebarCancelAction: def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self): """Session action menu should hide duplicate/delete for CLI-origin sessions.""" - # Window bumped 3600 → 4800 in #1764 (Rename action prepended). - body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4800) + # Window bumped 3600 → 4800 in #1764 (Rename action prepended), then + # to 5200 in #2111 for response-aware archive toast handling. + body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200) assert "const isCliSession = _isCliSession(session);" in body assert "const isExternalSession = isMessagingSession || isCliSession;" in body assert "if(!isExternalSession)" in body diff --git a/tests/test_issue2057_worktree_ui_static.py b/tests/test_issue2057_worktree_ui_static.py index 9e1653d4..b9233280 100644 --- a/tests/test_issue2057_worktree_ui_static.py +++ b/tests/test_issue2057_worktree_ui_static.py @@ -24,6 +24,7 @@ def test_batch_archive_delete_confirmations_count_worktree_sessions(): src = read("static/sessions.js") i18n = read("static/i18n.js") assert "function _worktreeSessionCount(ids)" in src + assert "function _worktreeResponseCount(results)" in src assert "session_batch_delete_worktree_confirm" in src assert "session_batch_archive_worktree_confirm" in src assert "session_batch_delete_worktree_confirm" in i18n @@ -43,6 +44,23 @@ def test_archive_and_delete_action_descriptions_are_worktree_specific(): assert "session_archived_worktree: 'Session archived. Worktree remains on disk.'" in i18n +def test_archive_delete_success_copy_prefers_response_worktree_retained(): + src = read("static/sessions.js") + assert "function _sessionResponseRetainsWorktree(response, session)" in src + assert "typeof response.worktree_retained==='boolean'" in src + assert "return response.worktree_retained;" in src + assert "return !!(session&&session.worktree_path);" in src + assert src.index("return response.worktree_retained;") < src.index( + "return !!(session&&session.worktree_path);" + ) + assert "function _sessionArchiveToast(response, session)" in src + assert "session.archived?_sessionArchiveToast(response,session):t('session_restored')" in src + assert "_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree')" in src + assert "const retainedCount=_worktreeResponseCount(results)" in src + assert "showToast(retainedCount?t('session_archived_worktree'):t('session_archived'))" in src + assert "showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))" in src + + def test_worktree_archive_delete_api_responses_are_explicit(): src = read("api/routes.py") assert "def _worktree_retained_payload(session)" in src From 573fc25f9680edef01dd1253bc352efdb8454ab8 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Mon, 11 May 2026 21:46:24 -0600 Subject: [PATCH 03/28] fix(providers): load Codex quota from credential pool --- api/providers.py | 185 +++++++++++++++++++++++++++- tests/test_provider_quota_status.py | 151 +++++++++++++++++++++++ 2 files changed, 335 insertions(+), 1 deletion(-) diff --git a/api/providers.py b/api/providers.py index ed44c94c..9734e111 100644 --- a/api/providers.py +++ b/api/providers.py @@ -125,12 +125,19 @@ def _account_usage_preexec_fn() -> None: _ACCOUNT_USAGE_SUBPROCESS_CODE = r""" +import base64 import json import sys +from datetime import datetime, timezone +from types import SimpleNamespace +from urllib import request as urllib_request from agent.account_usage import fetch_account_usage +_CODEX_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex" + + def _iso(value): if value in (None, ""): return None @@ -165,9 +172,185 @@ def _snapshot_payload(snapshot): } +def _snapshot_available(snapshot): + if snapshot is None: + return False + try: + return bool(getattr(snapshot, "available", False)) + except Exception: + return False + + +def _number(value): + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + return value + try: + text = str(value).strip() + if not text: + return None + number = float(text) + return int(number) if number.is_integer() else number + except Exception: + return None + + +def _parse_dt(value): + if value in (None, ""): + return None + if isinstance(value, (int, float)): + try: + return datetime.fromtimestamp(float(value), tz=timezone.utc) + except Exception: + return None + text = str(value).strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + +def _title_case_slug(value): + cleaned = str(value or "").strip() + if not cleaned: + return None + return cleaned.replace("_", " ").replace("-", " ").title() + + +def _resolve_codex_usage_url(base_url): + normalized = str(base_url or "").strip().rstrip("/") or _CODEX_DEFAULT_BASE_URL + if normalized.endswith("/codex"): + normalized = normalized[: -len("/codex")] + if "/backend-api" in normalized: + return normalized + "/wham/usage" + return normalized + "/api/codex/usage" + + +def _jwt_claims(token): + if not isinstance(token, str) or token.count(".") != 2: + return {} + payload = token.split(".")[1] + payload += "=" * ((4 - len(payload) % 4) % 4) + try: + claims = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8")) + except Exception: + return {} + return claims if isinstance(claims, dict) else {} + + +def _codex_usage_headers(access_token): + headers = { + "Authorization": "Bearer " + access_token, + "Accept": "application/json", + "User-Agent": "codex_cli_rs/0.0.0 (Hermes WebUI)", + "originator": "codex_cli_rs", + } + auth_claim = _jwt_claims(access_token).get("https://api.openai.com/auth") + account_id = None + if isinstance(auth_claim, dict): + account_id = auth_claim.get("chatgpt_account_id") + if isinstance(account_id, str) and account_id.strip(): + headers["ChatGPT-Account-ID"] = account_id.strip() + return headers + + +def _entry_value(entry, *names): + for name in names: + try: + value = getattr(entry, name) + except Exception: + value = None + if value in (None, ""): + continue + text = str(value).strip() + if text: + return text + return None + + +def _codex_snapshot_from_usage_payload(payload): + if not isinstance(payload, dict): + payload = {} + rate_limit = payload.get("rate_limit") + if not isinstance(rate_limit, dict): + rate_limit = {} + windows = [] + for key, label in (("primary_window", "Session"), ("secondary_window", "Weekly")): + window = rate_limit.get(key) + if not isinstance(window, dict): + continue + used = _number(window.get("used_percent")) + if used is None: + continue + windows.append(SimpleNamespace( + label=label, + used_percent=float(used), + reset_at=_parse_dt(window.get("reset_at")), + detail=None, + )) + + details = [] + credits = payload.get("credits") + if isinstance(credits, dict) and credits.get("has_credits"): + balance = _number(credits.get("balance")) + if balance is not None: + details.append("Credits balance: $" + format(float(balance), ".2f")) + elif credits.get("unlimited"): + details.append("Credits balance: unlimited") + + return SimpleNamespace( + provider="openai-codex", + source="usage_api", + title="Account limits", + plan=_title_case_slug(payload.get("plan_type")), + windows=tuple(windows), + details=tuple(details), + available=bool(windows or details), + unavailable_reason=None, + fetched_at=datetime.now(timezone.utc), + ) + + +def _fetch_codex_account_usage_from_pool(): + try: + from agent.credential_pool import load_pool + + pool = load_pool("openai-codex") + entry = pool.select() if pool is not None else None + if entry is None: + return None + access_token = _entry_value(entry, "runtime_api_key", "access_token") + if not access_token: + return None + base_url = _entry_value(entry, "runtime_base_url", "base_url") or _CODEX_DEFAULT_BASE_URL + request = urllib_request.Request( + _resolve_codex_usage_url(base_url), + headers=_codex_usage_headers(access_token), + ) + with urllib_request.urlopen(request, timeout=15.0) as response: + payload = json.loads(response.read().decode("utf-8") or "{}") + return _codex_snapshot_from_usage_payload(payload) + except Exception: + return None + + provider = sys.argv[1] api_key = sys.argv[2] or None -print(json.dumps(_snapshot_payload(fetch_account_usage(provider, api_key=api_key)))) +try: + snapshot = fetch_account_usage(provider, api_key=api_key) +except Exception: + snapshot = None +if str(provider or "").strip().lower() == "openai-codex" and not _snapshot_available(snapshot): + fallback_snapshot = _fetch_codex_account_usage_from_pool() + if _snapshot_available(fallback_snapshot) or snapshot is None: + snapshot = fallback_snapshot +print(json.dumps(_snapshot_payload(snapshot))) """ # SECTION: Provider ↔ env var mapping diff --git a/tests/test_provider_quota_status.py b/tests/test_provider_quota_status.py index fa2d769b..8da72e98 100644 --- a/tests/test_provider_quota_status.py +++ b/tests/test_provider_quota_status.py @@ -2,10 +2,13 @@ from __future__ import annotations +import base64 import json import inspect import os +import sys import threading +import types import urllib.error from datetime import datetime, timezone from io import BytesIO @@ -275,6 +278,154 @@ def test_codex_account_usage_unavailable_is_sanitized(monkeypatch, tmp_path): assert "secret" not in repr(result).lower() +def test_codex_account_usage_subprocess_falls_back_to_credential_pool(monkeypatch, capsys): + """Codex quota probes should use credential_pool credentials when legacy auth misses.""" + import api.providers as providers + + def b64url(payload: bytes) -> str: + return base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") + + token = ".".join(( + b64url(b'{"alg":"none","typ":"JWT"}'), + b64url(json.dumps({ + "https://api.openai.com/auth": { + "chatgpt_account_id": "acct-test-123", + }, + }).encode("utf-8")), + b64url(b"signature"), + )) + + fetch_calls = [] + load_pool_calls = [] + selected = [] + seen = {} + + agent_mod = types.ModuleType("agent") + agent_mod.__path__ = [] + account_usage_mod = types.ModuleType("agent.account_usage") + credential_pool_mod = types.ModuleType("agent.credential_pool") + + def fake_fetch_account_usage(provider, *, base_url=None, api_key=None): + fetch_calls.append((provider, base_url, api_key)) + return None + + class FakePool: + def select(self): + selected.append(True) + return SimpleNamespace( + runtime_api_key=token, + runtime_base_url="https://chatgpt.com/backend-api/codex", + ) + + def fake_load_pool(provider): + load_pool_calls.append(provider) + return FakePool() + + def fake_urlopen(req, timeout): + seen["url"] = req.full_url + seen["timeout"] = timeout + seen["headers"] = {key.lower(): value for key, value in req.header_items()} + payload = { + "plan_type": "pro", + "rate_limit": { + "primary_window": {"used_percent": 15, "reset_at": 1_900_000_000}, + "secondary_window": {"used_percent": 40, "reset_at": "2030-03-24T12:30:00Z"}, + }, + "credits": {"has_credits": True, "balance": 12.5}, + } + return _FakeResponse(json.dumps(payload).encode("utf-8")) + + account_usage_mod.fetch_account_usage = fake_fetch_account_usage + credential_pool_mod.load_pool = fake_load_pool + monkeypatch.setitem(sys.modules, "agent", agent_mod) + monkeypatch.setitem(sys.modules, "agent.account_usage", account_usage_mod) + monkeypatch.setitem(sys.modules, "agent.credential_pool", credential_pool_mod) + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(sys, "argv", ["quota-probe", "openai-codex", ""]) + + exec(providers._ACCOUNT_USAGE_SUBPROCESS_CODE, {"__name__": "__main__"}) + + output = capsys.readouterr().out.strip() + snapshot = json.loads(output) + + assert fetch_calls == [("openai-codex", None, None)] + assert load_pool_calls == ["openai-codex"] + assert selected == [True] + assert seen["url"] == "https://chatgpt.com/backend-api/wham/usage" + assert seen["timeout"] == 15.0 + headers = seen["headers"] + assert headers["authorization"] == f"Bearer {token}" + assert headers["accept"] == "application/json" + assert headers["originator"] == "codex_cli_rs" + assert headers["user-agent"].startswith("codex_cli_rs/") + assert headers["chatgpt-account-id"] == "acct-test-123" + assert snapshot["provider"] == "openai-codex" + assert snapshot["source"] == "usage_api" + assert snapshot["plan"] == "Pro" + assert snapshot["windows"][0]["label"] == "Session" + assert snapshot["windows"][0]["used_percent"] == 15.0 + assert snapshot["windows"][1]["label"] == "Weekly" + assert snapshot["details"] == ["Credits balance: $12.50"] + assert snapshot["available"] is True + assert token not in output + + +def test_codex_account_usage_subprocess_keeps_legacy_reason_when_pool_misses(monkeypatch, capsys): + """A failed pool fallback should not discard the legacy unavailable reason.""" + import api.providers as providers + + fetch_calls = [] + load_pool_calls = [] + + agent_mod = types.ModuleType("agent") + agent_mod.__path__ = [] + account_usage_mod = types.ModuleType("agent.account_usage") + credential_pool_mod = types.ModuleType("agent.credential_pool") + + def fake_fetch_account_usage(provider, *, base_url=None, api_key=None): + fetch_calls.append((provider, base_url, api_key)) + return SimpleNamespace( + provider="openai-codex", + source="usage_api", + title="Account limits", + plan=None, + windows=(), + details=(), + available=False, + unavailable_reason="Codex account limits are not available for this credential.", + fetched_at=datetime(2030, 3, 17, 12, 30, tzinfo=timezone.utc), + ) + + class EmptyPool: + def select(self): + return None + + def fake_load_pool(provider): + load_pool_calls.append(provider) + return EmptyPool() + + def explode_urlopen(*_args, **_kwargs): + raise AssertionError("no network call should happen when the pool has no selected entry") + + account_usage_mod.fetch_account_usage = fake_fetch_account_usage + credential_pool_mod.load_pool = fake_load_pool + monkeypatch.setitem(sys.modules, "agent", agent_mod) + monkeypatch.setitem(sys.modules, "agent.account_usage", account_usage_mod) + monkeypatch.setitem(sys.modules, "agent.credential_pool", credential_pool_mod) + monkeypatch.setattr(providers.urllib.request, "urlopen", explode_urlopen) + monkeypatch.setattr(sys, "argv", ["quota-probe", "openai-codex", ""]) + + exec(providers._ACCOUNT_USAGE_SUBPROCESS_CODE, {"__name__": "__main__"}) + + snapshot = json.loads(capsys.readouterr().out.strip()) + + assert fetch_calls == [("openai-codex", None, None)] + assert load_pool_calls == ["openai-codex"] + assert snapshot["available"] is False + assert snapshot["unavailable_reason"] == "Codex account limits are not available for this credential." + assert snapshot["fetched_at"] == "2030-03-17T12:30:00Z" + + def test_anthropic_oauth_usage_unavailable_reason_is_reported(monkeypatch, tmp_path): """Hermes Agent can report why account limits are not available.""" monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) From ff0830de4db84c0b6901791b63fc7ec6a94e4930 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Mon, 11 May 2026 22:08:32 -0600 Subject: [PATCH 04/28] fix(ui): smooth iPhone PWA bottom-edge bounce in chat --- static/ui.js | 33 ++++++++++++++++++- ...test_2111_ios_pwa_bottom_scroll_stutter.py | 22 +++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/test_2111_ios_pwa_bottom_scroll_stutter.py diff --git a/static/ui.js b/static/ui.js index 28f94817..3b305474 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1657,6 +1657,36 @@ let _messageUserUnpinned=false; let _bottomSettleToken=0; const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; const MESSAGE_UPWARD_INTENT_MS=450; +function _isIosStandalonePwa(){ + try{ + const ua=navigator.userAgent||''; + const isIosDevice=/iP(?:hone|ad|od)/i.test(ua) + || (navigator.platform==='MacIntel'&&navigator.maxTouchPoints>1); + if(!isIosDevice) return false; + const standalone=(typeof navigator!=='undefined'&&navigator.standalone===true) + || (typeof window.matchMedia==='function'&&window.matchMedia('(display-mode: standalone)').matches) + || (typeof window.matchMedia==='function'&&window.matchMedia('(display-mode: fullscreen)').matches); + if(!standalone) return false; + return typeof window.matchMedia!=='function' || window.matchMedia('(pointer: coarse)').matches; + }catch(_){ + return false; + } +} +function _messagePanePreferredBottomScrollTop(el){ + if(!el) return 0; + const maxTop=Math.max(0,el.scrollHeight-el.clientHeight); + if(maxTop<=1) return maxTop; + return _isIosStandalonePwa()?maxTop-1:maxTop; +} +function _maybeInsetIosStandaloneBottomEdge(el, top){ + if(!el||!_isIosStandalonePwa()) return; + const preferredTop=_messagePanePreferredBottomScrollTop(el); + if(preferredTop<=0||top{ setTimeout(()=>{_programmaticScroll=false;},0); }); +} function _cancelBottomSettle(){ _bottomSettleToken++; } function _recordNonMessageScrollIntent(e){ const el=document.getElementById('messages'); @@ -1716,6 +1746,7 @@ if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetS } else { _nearBottomCount=0; _scrollPinned=false; } if(_scrollPinned) _messageUserUnpinned=false; } // #1360 + _maybeInsetIosStandaloneBottomEdge(el, top); const btn=$('scrollToBottomBtn'); if(btn) btn.style.display=_scrollPinned?'none':'flex'; if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton(); @@ -2009,7 +2040,7 @@ function _setMessageScrollToBottom(){ const el=$('messages'); if(!el) return; _programmaticScroll=true; - el.scrollTop=el.scrollHeight; + el.scrollTop=_messagePanePreferredBottomScrollTop(el); _lastScrollTop=el.scrollTop; _nearBottomCount=2; _scrollPinned=true; diff --git a/tests/test_2111_ios_pwa_bottom_scroll_stutter.py b/tests/test_2111_ios_pwa_bottom_scroll_stutter.py new file mode 100644 index 00000000..ca58f7f6 --- /dev/null +++ b/tests/test_2111_ios_pwa_bottom_scroll_stutter.py @@ -0,0 +1,22 @@ +from pathlib import Path + +REPO = Path(__file__).parent.parent +UI_JS = (REPO / 'static' / 'ui.js').read_text(encoding='utf-8') + + +def test_ios_standalone_detection_helper_exists(): + assert 'function _isIosStandalonePwa()' in UI_JS + assert "window.matchMedia('(display-mode: standalone)').matches" in UI_JS + assert 'navigator.standalone===true' in UI_JS + + +def test_message_bottom_prefers_one_pixel_inset_on_ios_pwa(): + assert 'function _messagePanePreferredBottomScrollTop(el)' in UI_JS + assert 'return _isIosStandalonePwa()?maxTop-1:maxTop;' in UI_JS + assert 'el.scrollTop=_messagePanePreferredBottomScrollTop(el);' in UI_JS + + +def test_ios_pwa_bottom_edge_guard_installed_on_messages_pane(): + assert 'function _maybeInsetIosStandaloneBottomEdge(el, top)' in UI_JS + assert 'if(preferredTop<=0||top Date: Tue, 12 May 2026 10:34:02 +0530 Subject: [PATCH 05/28] fix: guard empty array iteration for bash 3.2 compatibility The _load_repo_dotenv_preserving_env() function iterates over ${preserved[@]} with set -euo pipefail. On bash 3.2 (macOS default), an empty array triggers 'unbound variable' under set -u, crashing ctl.sh start. Bash 4+ handles this fine, but macOS ships 3.2. Wraps the for loop in a length check: [[ ${#preserved[@]} -gt 0 ]] --- ctl.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ctl.sh b/ctl.sh index c246131f..1f41e497 100755 --- a/ctl.sh +++ b/ctl.sh @@ -51,9 +51,11 @@ _load_repo_dotenv_preserving_env() { set +a local assignment - for assignment in "${preserved[@]}"; do - export "${assignment}" - done + if [[ ${#preserved[@]} -gt 0 ]]; then + for assignment in "${preserved[@]}"; do + export "${assignment}" + done + fi } _find_python() { From 08b6dc4f41ee6013fd6db4a08c8794aa0f9f9dc3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 12 May 2026 05:13:31 +0000 Subject: [PATCH 06/28] =?UTF-8?q?docs:=20CHANGELOG=20stage-342=20=E2=80=94?= =?UTF-8?q?=20close=20v0.51.48,=20open=20Unreleased=20for=20#2109/#2113/#2?= =?UTF-8?q?116?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d516d36..a0feb95f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,22 @@ ### Added -- Read-only worktree status endpoint for the #2057 lifecycle tracker. `GET /api/session/worktree/status?session_id=...` now returns the session-owned worktree path, filesystem existence, dirty/untracked state, ahead/behind counts when an upstream is configured, and live stream/embedded-terminal lock flags without mutating git state. This is the non-destructive status surface Nathan requested before any future explicit remove-worktree action. +- **PR #2109** by @franksong2702 — Read-only worktree status endpoint for the #2057 lifecycle tracker. `GET /api/session/worktree/status?session_id=...` returns the session-owned worktree path, filesystem existence, dirty/untracked state, ahead/behind counts when an upstream is configured, and live stream/embedded-terminal lock flags. Uses `git worktree list --porcelain`, `git status --porcelain --untracked-files=normal`, and `rev-list --left-right --count HEAD...@{u}` only — no mutating git state, 5-second per-call timeouts. Session-id scoped (rejects non-worktree sessions with 400), does not accept arbitrary filesystem paths. This is the non-destructive status surface Nathan requested as the next slice before any future explicit remove-worktree action. 221-line regression suite covering clean/dirty/untracked/missing-path/live-stream-lock/embedded-terminal-lock/endpoint-success/non-worktree-rejection cases. + +### Fixed + +- **PR #2113** by @franksong2702 (closes #2111) — Session archive/delete success toasts now prefer the backend `worktree_retained` response over the cached session-sidebar snapshot. Pre-fix a stale sidebar snapshot (other browser tab archived the session, server-side mutation moved the worktree, etc.) could make the success toast say "worktree preserved on disk" even when the backend response said no worktree was retained. Frontend now treats `response.worktree_retained: true/false` as source of truth and falls back to `session.worktree_path` only when the backend doesn't return the flag (older-server compatibility). Both single-session and batch (Promise.all) archive/delete paths updated; batch retained-count derived from per-response flags instead of the pre-POST cached `_worktreeSessionCount`. The pre-flight confirm dialog still uses the cached snapshot (it renders before the POST exists), but the post-POST toast now reflects backend truth. + +- **PR #2116** by @starship-s — OpenAI Codex provider quota card no longer reports "unavailable" when Codex chat requests actually work. Runtime requests authenticate via the modern `agent.credential_pool`, but the account-usage probe only tried the legacy singleton Codex token path. Adds a Codex-only credential-pool fallback inside the existing isolated `_account_usage_subprocess`: when `agent.account_usage.fetch_account_usage()` returns no available snapshot, the fallback selects the active `openai-codex` credential-pool entry, derives the Codex usage endpoint from the runtime base URL (handles `/backend-api/codex` → `/wham/usage` and custom bases → `/api/codex/usage`), and serializes the existing snapshot shape expected by the WebUI. Stays inside the child process so active Hermes profile context remains isolated; legacy unavailable diagnostics preserved when the pool fallback can't produce a usable result; non-Codex providers unchanged. Returns only quota display data — never credential labels, access tokens, or raw exception strings. 151-line regression suite covers the success path, both URL-resolution branches, and the unavailable-fallthrough case. + +## [v0.51.48] — 2026-05-12 — Release X (stage-341 — 3 contributor PRs — Hermes run adapter RFC + title-retry loop fix on reasoning-only models + worktree archive/delete confirm copy) + +### Added - **PR #2105** by @Michaelyklam — Hermes run adapter contract RFC at `docs/rfcs/hermes-run-adapter-contract.md` (refs #1925). 315-line spec/gap matrix that defines the event/control compatibility contract WebUI needs before browser-originated chat turns can be routed to Hermes-owned runtime execution. Documents the ownership boundary (Hermes Agent owns run creation, lifecycle, event ordering, replay, terminal state, approvals, clarify, cancellation; WebUI owns browser auth, transcript rendering, tool cards, approval/clarify widgets, workspace UX), the minimum `start_run`/`observe_run`/`get_run`/`cancel_run`/`queue_or_continue`/`respond_approval`/`respond_clarify` IPC surface, and a gap matrix mapping current `STREAMS`/`CANCEL_FLAGS`/`AGENT_INSTANCES`/callback queues to Hermes-owned targets with explicit "no private callback queue" / "no runtime surrogate" non-goals. First success criterion is restart/reattach (start a non-trivial run, restart hermes-webui, browser reconnects, replays from last cursor, cancels with Hermes-emitted terminal state) — not "basic chat streamed once." Status: Proposed. ### Fixed -- Session archive/delete success copy now prefers the backend `worktree_retained` response over cached session snapshots, so stale sidebar state cannot show worktree-retention reassurance after the server says no worktree was retained (refs #2111). - - **PR #2107** (self-built, closes #2083) — Title-generation budget-doubling retry loop on reasoning-only model responses. Reporter @darkopetrovic on LM Studio with Qwen3.6-35B-A3B (and the broader class: DeepSeek-R1, Kimi-K2, other Qwen3-thinking variants) saw GPU never going idle after each prompt — the chat turn finished cleanly but the auto-title generation request burned its 500-token budget on hidden `reasoning_content`, emitted `content=""` with `finish_reason=length`, got classified as `llm_length`, retried at 1024 tokens, returned the same shape, then iterated through `_title_prompts()`'s two prompts for ~3000 reasoning tokens per new chat. The agent-side `is_lmstudio` classifier in `run_agent.py:9468` misses `custom:` providers pointing at LM Studio, so the `reasoning_effort: "none"` adapter never fires for that route. WebUI-side belt-and-braces fix: (1) `_extract_title_response()` reorders the empty-response classification to check `reasoning_content` first regardless of `finish_reason` — reasoning presence is the diagnostic signal, not finish_reason; (2) `_title_retry_status()` drops `llm_empty_reasoning{,_aux}` from the retry set (length-without-reasoning still retries — legitimate budget-truncation case); (3) new `_title_should_skip_remaining_attempts()` short-circuits the prompt-iteration loop, both aux and agent routes break to `_fallback_title_from_exchange` for a local-summary title. Net: 4 calls → 1 call per chat. `tests/test_title_aux_routing.py` inverts the old reasoning-retry assertions and adds two new tests for the legitimate length-without-reasoning retry path. nesquena APPROVED with 200-line end-to-end trace + behavioral harness confirming the 4→1 call reduction. - **PR #2064** by @franksong2702 — Worktree session archive/delete confirm copy now reassures users that the underlying worktree directory remains on disk (refs #2057). Pre-fix the confirm dialogs said only "Delete this conversation?" / "Archive this conversation?" without clarifying that worktree-backed conversations preserve the worktree files even when the conversation row is removed — users were reasonably afraid of losing local work. Adds an explicit `worktree_retained` boolean on the `/api/session` payload that the frontend reads to surface "The worktree at /path will remain on disk." (single) and "N worktree-backed conversation(s) will keep their worktree directories on disk." (bulk) variants in both archive and delete dialogs. 81-line i18n update across all 9 locales (en/it/ja/ru/es/de/zh/pt/ko) with an English-bundle locale-leak fix caught during screenshot capture (several worktree strings had landed under Russian in error). Regression coverage in `tests/test_issue2057_worktree_lifecycle.py` + `tests/test_issue2057_worktree_ui_static.py`. UX-gate cleared with 5 viewports (4×1280px desktop covering single + bulk archive/delete confirms, 1×390px mobile of single-delete confirming dialog fits without overflow). From 10cfcee30e676267f24bb0a5c47df846a29ba2d6 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 12 May 2026 05:22:01 +0000 Subject: [PATCH 07/28] =?UTF-8?q?stage-342:=20apply=20Opus=20SHOULD-FIX=20?= =?UTF-8?q?=E2=80=94=20tighten=20worktree=20status=20=5Frun=5Fgit=20timeou?= =?UTF-8?q?t=205s=20=E2=86=92=202s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worst case 4×5s=20s per polling request on ThreadingHTTPServer pool is risky given today's _cron_env_lock near-miss on production 8787. Status probes should fail fast; client can retry. All four call sites use default timeout. --- CHANGELOG.md | 7 ++++++- api/worktrees.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0feb95f..92664598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **PR #2109** by @franksong2702 — Read-only worktree status endpoint for the #2057 lifecycle tracker. `GET /api/session/worktree/status?session_id=...` returns the session-owned worktree path, filesystem existence, dirty/untracked state, ahead/behind counts when an upstream is configured, and live stream/embedded-terminal lock flags. Uses `git worktree list --porcelain`, `git status --porcelain --untracked-files=normal`, and `rev-list --left-right --count HEAD...@{u}` only — no mutating git state, 5-second per-call timeouts. Session-id scoped (rejects non-worktree sessions with 400), does not accept arbitrary filesystem paths. This is the non-destructive status surface Nathan requested as the next slice before any future explicit remove-worktree action. 221-line regression suite covering clean/dirty/untracked/missing-path/live-stream-lock/embedded-terminal-lock/endpoint-success/non-worktree-rejection cases. +- **PR #2109** by @franksong2702 — Read-only worktree status endpoint for the #2057 lifecycle tracker. `GET /api/session/worktree/status?session_id=...` returns the session-owned worktree path, filesystem existence, dirty/untracked state, ahead/behind counts when an upstream is configured, and live stream/embedded-terminal lock flags. Uses `git worktree list --porcelain`, `git status --porcelain --untracked-files=normal`, and `rev-list --left-right --count HEAD...@{u}` only — no mutating git state, 2-second per-call timeouts (tightened from PR-submitted 5s during stage review). Session-id scoped (rejects non-worktree sessions with 400), does not accept arbitrary filesystem paths. This is the non-destructive status surface Nathan requested as the next slice before any future explicit remove-worktree action. 221-line regression suite covering clean/dirty/untracked/missing-path/live-stream-lock/embedded-terminal-lock/endpoint-success/non-worktree-rejection cases. ### Fixed @@ -12,6 +12,11 @@ - **PR #2116** by @starship-s — OpenAI Codex provider quota card no longer reports "unavailable" when Codex chat requests actually work. Runtime requests authenticate via the modern `agent.credential_pool`, but the account-usage probe only tried the legacy singleton Codex token path. Adds a Codex-only credential-pool fallback inside the existing isolated `_account_usage_subprocess`: when `agent.account_usage.fetch_account_usage()` returns no available snapshot, the fallback selects the active `openai-codex` credential-pool entry, derives the Codex usage endpoint from the runtime base URL (handles `/backend-api/codex` → `/wham/usage` and custom bases → `/api/codex/usage`), and serializes the existing snapshot shape expected by the WebUI. Stays inside the child process so active Hermes profile context remains isolated; legacy unavailable diagnostics preserved when the pool fallback can't produce a usable result; non-Codex providers unchanged. Returns only quota display data — never credential labels, access tokens, or raw exception strings. 151-line regression suite covers the success path, both URL-resolution branches, and the unavailable-fallthrough case. + +### Stage-342 maintainer fixes + +- **`api/worktrees.py:_run_git` default timeout 5s → 2s** — Opus SHOULD-FIX from stage-342 review: PR #2109's new `/api/session/worktree/status` endpoint runs up to four `git` subprocess calls per request, each defaulting to a 5-second timeout. Worst case 20 seconds per polling request piling up on the `ThreadingHTTPServer` thread pool is risky given today's `_cron_env_lock` near-miss on production 8787. Status probes should fail fast — a worktree that takes longer than 2 seconds to enumerate is already in trouble, and the client can retry. Mechanical 1-LOC default-arg change; all four call sites already pass `cwd` positionally and rely on the default. ~1 LOC. + ## [v0.51.48] — 2026-05-12 — Release X (stage-341 — 3 contributor PRs — Hermes run adapter RFC + title-retry loop fix on reasoning-only models + worktree archive/delete confirm copy) ### Added diff --git a/api/worktrees.py b/api/worktrees.py index d792251b..e71fea6c 100644 --- a/api/worktrees.py +++ b/api/worktrees.py @@ -13,7 +13,7 @@ import logging logger = logging.getLogger(__name__) -def _run_git(args: list[str], cwd: str | Path, timeout: float = 5) -> subprocess.CompletedProcess: +def _run_git(args: list[str], cwd: str | Path, timeout: float = 2) -> subprocess.CompletedProcess: return subprocess.run( ["git", *args], cwd=str(cwd), From 8b8fa0b88519f420d04b96ce867c9ff4cb2d2daf Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 12 May 2026 05:36:31 +0000 Subject: [PATCH 08/28] stage-343: add bash 3.2 compat regression tests + CHANGELOG - New tests/test_ctl_bash32_compat.py (5 static-pattern assertions): * strict-mode is enabled (set -euo pipefail) * preserved[@] iteration is length-guarded (PR #2117) * CTL_BOOTSTRAP_ARGS[@] uses +alt expansion (commit 025f137f) * defense-in-depth: catch any future raw "${arr[@]}" w/o whitelist * denylist of bash 4+ features (declare -A, mapfile, [[ -v ]], etc.) - Verified test fails when fix reverted, passes when restored. - CHANGELOG: close v0.51.49, open Unreleased for #2117. --- CHANGELOG.md | 6 ++ tests/test_ctl_bash32_compat.py | 158 ++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 tests/test_ctl_bash32_compat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 92664598..6115c51f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Fixed + +- **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh. + +## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback) + ### Added - **PR #2109** by @franksong2702 — Read-only worktree status endpoint for the #2057 lifecycle tracker. `GET /api/session/worktree/status?session_id=...` returns the session-owned worktree path, filesystem existence, dirty/untracked state, ahead/behind counts when an upstream is configured, and live stream/embedded-terminal lock flags. Uses `git worktree list --porcelain`, `git status --porcelain --untracked-files=normal`, and `rev-list --left-right --count HEAD...@{u}` only — no mutating git state, 2-second per-call timeouts (tightened from PR-submitted 5s during stage review). Session-id scoped (rejects non-worktree sessions with 400), does not accept arbitrary filesystem paths. This is the non-destructive status surface Nathan requested as the next slice before any future explicit remove-worktree action. 221-line regression suite covering clean/dirty/untracked/missing-path/live-stream-lock/embedded-terminal-lock/endpoint-success/non-worktree-rejection cases. diff --git a/tests/test_ctl_bash32_compat.py b/tests/test_ctl_bash32_compat.py new file mode 100644 index 00000000..7e05205e --- /dev/null +++ b/tests/test_ctl_bash32_compat.py @@ -0,0 +1,158 @@ +"""Regression tests pinning bash 3.2 compatibility patterns in ctl.sh. + +macOS still ships bash 3.2 as the default ``/usr/bin/bash``. Under +``set -euo pipefail`` (which ctl.sh sets at the top of the file), bash 3.2 +treats *empty array expansion* as referencing an unbound variable and aborts +with ``preserved[@]: unbound variable`` (or equivalent). Bash 4+ silently +handles empty arrays. We can't realistically run the CI suite under bash 3.2, +so these are static-pattern assertions on the source file -- if a future PR +introduces a raw ``"${arr[@]}"`` expansion without the established guards, +this test fails fast. + +Two guard patterns are used in ctl.sh: + +1. Length-guarded ``for`` loop:: + + if [[ ${#preserved[@]} -gt 0 ]]; then + for assignment in "${preserved[@]}"; do + export "${assignment}" + done + fi + + Used when the loop body has side effects we want to skip when empty. + (PR #2117 introduced this pattern at the ``preserved`` site.) + +2. Inline ``${arr[@]+...}`` expansion:: + + exec ... ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"} + + Used when we want to pass-through the array to a command and have the + expansion produce nothing when empty. (PR ``025f137f`` introduced this + pattern at the ``CTL_BOOTSTRAP_ARGS`` site.) + +Either pattern is acceptable -- a raw ``"${arr[@]}"`` without one of them is +not. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CTL_SH = REPO_ROOT / "ctl.sh" + + +def _read_ctl() -> str: + return CTL_SH.read_text(encoding="utf-8") + + +def test_ctl_sh_sets_strict_mode() -> None: + """ctl.sh must keep ``set -euo pipefail`` -- the bug class only triggers under -u.""" + src = _read_ctl() + assert "set -euo pipefail" in src, ( + "ctl.sh must use strict-mode `set -euo pipefail`; otherwise the bash 3.2 " + "empty-array guards we're pinning here are unnecessary and the file lost " + "its bug-class coverage." + ) + + +def test_preserved_array_is_length_guarded_before_iteration() -> None: + """The dotenv-preserve loop must guard against empty `preserved=()` on bash 3.2. + + PR #2117 (ayushere) — guards the iteration with + ``if [[ ${#preserved[@]} -gt 0 ]]; then ... fi``. Without the guard, bash + 3.2 on macOS aborts ``ctl.sh start`` before bootstrap even launches. + """ + src = _read_ctl() + # Must have the length guard somewhere upstream of the for-loop iteration. + guarded = re.search( + r"if\s+\[\[\s+\$\{#preserved\[@\]\}\s+-gt\s+0\s+\]\];\s*then\s*" + r"\s*for\s+\w+\s+in\s+\"\$\{preserved\[@\]\}\"", + src, + ) + assert guarded, ( + "Raw `for assignment in \"${preserved[@]}\"` iteration crashes under " + "bash 3.2 + set -u when no preserved env keys overlap with .env. " + "Wrap the loop in `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` " + "(PR #2117)." + ) + + +def test_ctl_bootstrap_args_uses_plus_alternate_expansion() -> None: + """The exec line must use ``${CTL_BOOTSTRAP_ARGS[@]+...}`` for empty-safe pass-through. + + PR ``025f137f`` — bash 3.2 + ``set -u`` treats ``"${CTL_BOOTSTRAP_ARGS[@]}"`` + as an unbound reference when the array is empty. The ``+alt`` parameter + expansion produces nothing when unset and our quoted expansion otherwise, + which is the canonical bash 3.2 / strict-mode pattern. + """ + src = _read_ctl() + has_plus_alt = re.search( + r"\$\{CTL_BOOTSTRAP_ARGS\[@\]\+\"\$\{CTL_BOOTSTRAP_ARGS\[@\]\}\"\}", + src, + ) + assert has_plus_alt, ( + "exec line must use `${CTL_BOOTSTRAP_ARGS[@]+\"${CTL_BOOTSTRAP_ARGS[@]}\"}` " + "so an empty CTL_BOOTSTRAP_ARGS doesn't trip bash 3.2 + set -u. " + "See commit 025f137f." + ) + + +def test_no_array_iteration_without_guard_in_ctl() -> None: + """Defense-in-depth: catch *any* future raw array expansion not protected by a guard. + + Whitelist the two known-safe sites (preserved + CTL_BOOTSTRAP_ARGS). Any + other ``"${SOMETHING[@]}"`` expansion in ctl.sh should also use one of the + two established empty-safe patterns; this test surfaces the new site so the + author can decide which. + """ + src = _read_ctl() + # Match every quoted-all-elements expansion outside the +alt form. + raw_expansions = re.findall(r'"\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\}"', src) + # Already-allowed names (each has its own dedicated regression test above). + allowed = {"preserved", "CTL_BOOTSTRAP_ARGS"} + new_unguarded = [name for name in raw_expansions if name not in allowed] + assert not new_unguarded, ( + "New raw `\"${{{name}[@]}}\"` array expansion(s) appeared in ctl.sh: " + "{names}. On bash 3.2 + `set -u` (macOS default), iterating or " + "expanding an empty array aborts the script. Wrap iteration in " + "`if [[ ${{#arr[@]}} -gt 0 ]]; then ... fi` (loop-side-effect " + "pattern, see preserved at line ~54) or use " + "`${{arr[@]+\"${{arr[@]}}\"}}` (pass-through pattern, see " + "CTL_BOOTSTRAP_ARGS at line ~220) — then whitelist the name in " + "`tests/test_ctl_bash32_compat.py::test_no_array_iteration_without_guard_in_ctl`." + ).format(name=new_unguarded[0] if new_unguarded else "?", names=new_unguarded) + + +def test_no_bash4_plus_features_in_ctl() -> None: + """Guard against accidental introduction of bash 4+ syntax in ctl.sh. + + macOS bash 3.2 does not support: + - ``declare -A`` / ``local -A`` (associative arrays) + - ``mapfile`` / ``readarray`` (line-into-array readers) + - ``[[ -v VAR ]]`` (variable-existence test, bash 4.2+) + - ``${var^^}`` / ``${var,,}`` (case toggle) + + A prior fix (commit 630981a0) replaced ``[[ -v ${key} ]]`` with + ``[[ -n "${!key+x}" ]]`` specifically because of the macOS bash 3.2 issue. + Keep that gain by pinning the absence of the bash 4+ patterns. + """ + src = _read_ctl() + + forbidden = { + "declare -A": r"\bdeclare\s+-A\b", + "local -A": r"\blocal\s+-A\b", + "mapfile": r"\bmapfile\b", + "readarray": r"\breadarray\b", + "[[ -v VAR ]]": r"\[\[\s*-v\s+", + "${var^^}": r"\$\{[A-Za-z_][A-Za-z0-9_]*\^\^?\}", + "${var,,}": r"\$\{[A-Za-z_][A-Za-z0-9_]*,,?\}", + } + found = [name for name, pat in forbidden.items() if re.search(pat, src)] + assert not found, ( + f"ctl.sh introduced bash 4+ feature(s) {found} — these break macOS's " + "default bash 3.2. Use a 3.2-compatible alternative; see commit " + "630981a0 for the `-v` → `\"${!key+x}\"` substitution pattern." + ) From 245288c00d79f8eccae748f2585a91da4515d94b Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 11 May 2026 23:01:06 -0700 Subject: [PATCH 09/28] fix: bucket long-range daily token charts --- .../2103/daily-tokens-365-desktop.png | Bin 0 -> 60114 bytes static/panels.js | 79 +++++++++-- static/style.css | 2 +- tests/test_insights.py | 130 ++++++++++++++++++ 4 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 docs/pr-media/2103/daily-tokens-365-desktop.png diff --git a/docs/pr-media/2103/daily-tokens-365-desktop.png b/docs/pr-media/2103/daily-tokens-365-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..ab1a419653066ecb669296133dbfb5c6d9d6976d GIT binary patch literal 60114 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 OtSG1UwD^hHn|}iqY9JB- literal 0 HcmV?d00001 diff --git a/static/panels.js b/static/panels.js index 18548394..08b4f0d3 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2918,6 +2918,66 @@ function _renderLlmWikiStatus(d) { `; } +/** + * Bucket daily token rows for chart display. + * Returns rows unchanged when length <= 30 (per-day resolution). + * For longer ranges, groups consecutive days into buckets: + * 31–90 days → 2-day buckets + * 91–180 days → 3-day buckets + * 181–365 days → 8-day buckets + * Result is always <= ~52 bars. + * Each bucket row has: + * - label: short label for axis (e.g. MM-DD or MM-DD–MM-DD) + * - title: full tooltip title (e.g. 2026-01-01 – 2026-01-05) + * - date: first date in bucket (used for date label slicing) + * - input_tokens, output_tokens, sessions, cost: summed across bucket + */ +function _bucketDailyTokensForChart(rows) { + if (!Array.isArray(rows) || rows.length === 0) return []; + const len = rows.length; + if (len <= 30) return rows; // per-day resolution for 7/30-day ranges + + // Target <= 75 bars; derive bucket size + let bucketSize; + if (len <= 90) { + bucketSize = 2; + } else if (len <= 180) { + bucketSize = 3; + } else if (len <= 365) { + bucketSize = 8; // <=52 bars for 365 days (ceil(365/8)=46) + } else { + bucketSize = 8; // fallback for >365 (shouldn't occur in practice) + } + + const result = []; + for (let i = 0; i < len; i += bucketSize) { + const slice = rows.slice(i, i + bucketSize); + const input_tokens = slice.reduce((s, r) => s + Number(r.input_tokens || 0), 0); + const output_tokens = slice.reduce((s, r) => s + Number(r.output_tokens || 0), 0); + const sessions = slice.reduce((s, r) => s + Number(r.sessions || 0), 0); + const cost = slice.reduce((s, r) => s + Number(r.cost || 0), 0); + + const firstDate = slice[0].date; + const lastDate = slice[slice.length - 1].date; + + // Label: short form for axis + const firstLabel = String(firstDate).slice(5); // MM-DD + const lastLabel = String(lastDate).slice(5); + const label = (firstDate === lastDate) ? firstLabel : (firstLabel + '–' + lastLabel); + + result.push({ + label, + title: firstDate + (firstDate !== lastDate ? ' – ' + lastDate : ''), + date: firstDate, + input_tokens, + output_tokens, + sessions, + cost, + }); + } + return result; +} + function _renderInsights(d, box, wikiStatus) { const fmtNum = n => Number(n || 0).toLocaleString(); const fmtCost = c => { @@ -2937,21 +2997,24 @@ function _renderInsights(d, box, wikiStatus) { { label: t('insights_cost'), value: fmtCost(d.total_cost), icon: li('dollar-sign', 18) }, ]; - // Daily token trend + // Daily token trend — bucket long ranges to avoid horizontal overflow const dailyTokens = Array.isArray(d.daily_tokens) ? d.daily_tokens : []; + const chartRows = _bucketDailyTokensForChart(dailyTokens); let dailyHtml = ''; - if (dailyTokens.length) { - const maxDailyTokens = Math.max(...dailyTokens.map(r => Number(r.input_tokens || 0) + Number(r.output_tokens || 0)), 1); - const labelEvery = Math.max(Math.ceil(dailyTokens.length / 7), 1); + if (chartRows.length) { + const maxDailyTokens = Math.max(...chartRows.map(r => Number(r.input_tokens || 0) + Number(r.output_tokens || 0)), 1); + const labelEvery = Math.max(Math.ceil(chartRows.length / 7), 1); dailyHtml = `

`; } else { diff --git a/static/style.css b/static/style.css index 7e87920f..5e871524 100644 --- a/static/style.css +++ b/static/style.css @@ -3370,7 +3370,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .insights-model-cost,.insights-model-tokens{font-variant-numeric:tabular-nums;} .insights-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .insights-empty{font-size:12px;color:var(--muted);padding:12px 0;} -.insights-daily-token-chart{height:180px;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(10px,1fr);gap:4px;align-items:end;padding:6px 0 2px;border-bottom:1px solid var(--border);} +.insights-daily-token-chart{height:180px;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:4px;align-items:end;padding:6px 0 2px;border-bottom:1px solid var(--border);overflow:hidden;max-width:100%;} .insights-daily-bar{min-width:0;height:100%;display:flex;flex-direction:column;justify-content:flex-end;gap:4px;} .insights-daily-stack{height:150px;display:flex;flex-direction:column;justify-content:flex-end;background:var(--border,.15);border-radius:4px;overflow:hidden;} .insights-daily-bar-input{background:var(--accent);min-height:0;} diff --git a/tests/test_insights.py b/tests/test_insights.py index 3bc48fc7..fe2501a2 100644 --- a/tests/test_insights.py +++ b/tests/test_insights.py @@ -162,3 +162,133 @@ def test_insights_frontend_has_daily_chart_styles_and_range_switching_hooks(): assert ".insights-daily-token-chart" in STYLE_CSS assert ".insights-daily-bar-output" in STYLE_CSS assert ".insights-model-cost" in STYLE_CSS + + +def _make_daily_rows(n): + rows = [] + for i in range(n): + rows.append({ + 'date': f'2026-01-{i+1:02d}', + 'input_tokens': (i + 1) * 100, + 'output_tokens': (i + 1) * 50, + 'sessions': 1, + 'cost': (i + 1) * 0.01, + }) + return rows + + +# Python reference implementation of the JS bucketing logic, so we can +# verify the JS implementation produces the same behavior without needing +# a JS runtime. +def _py_bucket(rows): + if not isinstance(rows, list) or len(rows) == 0: + return [] + n = len(rows) + if n <= 30: + return list(rows) # unchanged + + if n <= 90: + bucket_size = 2 + elif n <= 180: + bucket_size = 3 + elif n <= 365: + bucket_size = 8 # ≤52 bars for 365 days; shrink-safe with minmax(0,1fr) + else: + bucket_size = 8 # fallback for >365 (shouldn't occur in practice) + + result = [] + for i in range(0, n, bucket_size): + sl = rows[i:i + bucket_size] + inp = sum(r['input_tokens'] for r in sl) + out = sum(r['output_tokens'] for r in sl) + sess = sum(r['sessions'] for r in sl) + cost = sum(r['cost'] for r in sl) + first = sl[0]['date'] + last = sl[-1]['date'] + first_lbl = first[5:] # MM-DD + last_lbl = last[5:] + result.append({ + 'label': (first_lbl if first == last else first_lbl + '--' + last_lbl), + 'title': first + (' -- ' + last if first != last else ''), + 'date': first, + 'input_tokens': inp, + 'output_tokens': out, + 'sessions': sess, + 'cost': cost, + }) + return result + + +def test_insights_bucketing_helper_preserves_short_ranges(): + # _bucketDailyTokensForChart must exist in panels.js + assert '_bucketDailyTokensForChart' in PANELS_JS + + # 7-day: unchanged (≤ 30 threshold) + rows7 = _make_daily_rows(7) + bucketed7 = _py_bucket(rows7) + assert len(bucketed7) == 7, f'7-day should stay 7 bars, got {len(bucketed7)}' + assert bucketed7[0]['input_tokens'] == 100 + + # 30-day: exactly 30 → unchanged + rows30 = _make_daily_rows(30) + bucketed30 = _py_bucket(rows30) + assert len(bucketed30) == 30, f'30-day should stay 30 bars, got {len(bucketed30)}' + + # 31-day: bucketed + rows31 = _make_daily_rows(31) + bucketed31 = _py_bucket(rows31) + assert len(bucketed31) < 31, f'31-day should be bucketed, got {len(bucketed31)}' + assert len(bucketed31) <= 16 # ceil(31/2) + + +def test_insights_bucketing_helper_bounds_long_ranges(): + # 90-day → 2-day buckets → 45 bars + rows90 = _make_daily_rows(90) + bucketed90 = _py_bucket(rows90) + assert len(bucketed90) <= 45, f'90-day should be <=45 bars, got {len(bucketed90)}' + assert len(bucketed90) > 0 + + # 365-day → 8-day buckets → 46 bars (≤52 threshold) + rows365 = _make_daily_rows(365) + bucketed365 = _py_bucket(rows365) + assert len(bucketed365) <= 52, f'365-day should be <=52 bars, got {len(bucketed365)}' + assert len(bucketed365) > 0 + # First bucket has 8 days: 100+200+300+400+500+600+700+800 = 3600 + assert bucketed365[0]['input_tokens'] == 3600 + assert bucketed365[0]['sessions'] == 8 + + +def test_insights_bucketing_helper_preserves_label_and_title_fields(): + # Short range → rows unchanged; no .label/.title keys + rows10 = _make_daily_rows(10) + bucketed10 = _py_bucket(rows10) + assert bucketed10[0]['date'] == '2026-01-01' + assert 'label' not in bucketed10[0] + assert 'title' not in bucketed10[0] + + # 90-day → bucket rows have .label and .title + rows90 = _make_daily_rows(90) + bucketed90 = _py_bucket(rows90) + assert 'label' in bucketed90[0], 'bucket row must have .label' + assert 'title' in bucketed90[0], 'bucket row must have .title' + assert '2026-01-01' in bucketed90[0]['title'], f'title should include start date, got {bucketed90[0]["title"]}' + assert len(bucketed90[0]['label']) <= 12, f'label should be short, got {bucketed90[0]["label"]}' + + +def test_insights_render_loop_uses_bucket_helper(): + src = PANELS_JS + daily_section_start = src.find('// Daily token trend') + daily_section_end = src.find('// Models table', daily_section_start) + daily_section = src[daily_section_start:daily_section_end] + + assert '_bucketDailyTokensForChart' in daily_section, '_bucketDailyTokensForChart must be called in the render loop' + assert 'const chartRows' in daily_section, 'chartRows variable must be used instead of dailyTokens.map directly' + + +def test_insights_css_chart_shrink_safe(): + assert '.insights-daily-token-chart' in STYLE_CSS + chart_line = [line for line in STYLE_CSS.splitlines() if '.insights-daily-token-chart' in line][0] + # minmax(0,1fr) instead of minmax(12px,1fr) lets long-range bars shrink to fit the card + assert 'minmax(0,1fr)' in chart_line, f'chart must use minmax(0,1fr) for shrink-safe columns, got: {chart_line}' + assert 'overflow:hidden' in chart_line, 'chart must have overflow:hidden to prevent horizontal scroll' + assert 'max-width:100%' in chart_line or 'max-width' in chart_line, 'chart should constrain max-width' From a41b4d5afc9c748d963f3b81fb16aad390b837ab Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 11 May 2026 23:07:35 -0700 Subject: [PATCH 10/28] fix: stack analytics usage cards on mobile --- static/panels.js | 2 +- static/style.css | 14 ++++++++++++++ tests/test_insights.py | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/static/panels.js b/static/panels.js index 18548394..59c5873d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3023,7 +3023,7 @@ function _renderInsights(d, box, wikiStatus) { ${overviewCards.map(c => `
${c.icon}
${c.value}
${esc(c.label)}
`).join('')} ${dailyHtml} -
+
${tokenCards} ${modelsHtml}
diff --git a/static/style.css b/static/style.css index 7e87920f..67ee863e 100644 --- a/static/style.css +++ b/static/style.css @@ -3392,6 +3392,20 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .insights-token-label{color:var(--muted);} .insights-token-value{font-weight:600;} +/* ── Mobile layout for Token Breakdown + Models (issue #2104) ───────────── */ +@media (max-width: 640px) { + .insights-usage-grid { + grid-template-columns: 1fr; + } + .insights-usage-grid .insights-card { + min-width: 0; + overflow-x: auto; + } + .insights-model-table { + min-width: 320px; + } +} + /* ── Checkpoints / Rollback UI (#466) ─────────────────────────────────────── */ .checkpoint-list{display:flex;flex-direction:column;gap:8px;} .checkpoint-item{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--surface-2);border:1px solid var(--border);border-radius:6px;font-size:12px;} diff --git a/tests/test_insights.py b/tests/test_insights.py index 3bc48fc7..0210b732 100644 --- a/tests/test_insights.py +++ b/tests/test_insights.py @@ -162,3 +162,27 @@ def test_insights_frontend_has_daily_chart_styles_and_range_switching_hooks(): assert ".insights-daily-token-chart" in STYLE_CSS assert ".insights-daily-bar-output" in STYLE_CSS assert ".insights-model-cost" in STYLE_CSS +def test_insights_mobile_layout_stacks_usage_grid(): + # Regression test for issue #2104: Token Breakdown + Models should + # stack on mobile instead of being side-by-side causing horizontal overflow + assert 'insights-usage-grid' in PANELS_JS + # Scoped mobile breakpoint that forces single-column layout + assert '@media (max-width: 640px)' in STYLE_CSS + assert '.insights-usage-grid' in STYLE_CSS + assert 'grid-template-columns: 1fr' in STYLE_CSS + + +def test_insights_mobile_models_table_has_contained_overflow(): + # Regression test for issue #2104: Models table should have contained + # horizontal scrolling instead of pushing the whole page off-screen + assert 'insights-model-table' in PANELS_JS + # The mobile rule should include overflow-x handling for the models card/table + # Search for the specific mobile rule that contains insights-usage-grid + insights_mobile = '/* ── Mobile layout for Token Breakdown + Models' + assert insights_mobile in STYLE_CSS, 'Issue #2104 mobile rules should exist in CSS' + # Get the block from our specific mobile section to the next section comment + section_start = STYLE_CSS.find(insights_mobile) + section_end = STYLE_CSS.find('/* ── Checkpoints', section_start) + section_block = STYLE_CSS[section_start:section_end] + assert 'overflow-x' in section_block, 'Mobile rule should include overflow-x handling' + assert 'insights-model-table' in section_block or 'insights-card' in section_block From 7a16d09f106c4cf6bbf3bcde30a57dd616817ce6 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 11 May 2026 23:23:41 -0700 Subject: [PATCH 11/28] fix: add Portuguese session management i18n --- static/i18n.js | 7 +++++++ tests/test_login_locale_parity.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index 84643cab..c4d03526 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -8947,6 +8947,13 @@ const LOCALES = { session_deleted_worktree: 'Conversa excluída. O worktree permanece no disco.', 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?', + session_batch_archive_confirm: 'Arquivar {0} conversas?', + session_select_mode: 'Selecionar', + session_select_mode_desc: 'Selecionar conversas para gerenciamento em lote', + session_select_all: 'Selecionar todas', + session_selected_count: '{0} selecionadas', + session_no_selection: 'Nenhuma conversa selecionada', // settings panel settings_heading_title: 'Control Center', settings_heading_subtitle: 'Preferências, ferramentas de conversa e controles do sistema.', diff --git a/tests/test_login_locale_parity.py b/tests/test_login_locale_parity.py index ff4a4b2b..77392f96 100644 --- a/tests/test_login_locale_parity.py +++ b/tests/test_login_locale_parity.py @@ -328,3 +328,33 @@ def test_login_flow_keys_are_translated(loc_key: str): f"Locale {loc_key!r} leaks English for login-flow keys: {leaks}. " f"Translate these in static/i18n.js (issue #1442)." ) + + +# ── Session-management key parity ───────────────────────────────────────────── +# +# Keys added for session batch operations and multi-select (#2112). +# Every locale block must have these keys; missing them falls back to English +# which is a regression for non-English users. + +SESSION_MANAGEMENT_KEYS = ( + "session_batch_delete_confirm", + "session_batch_archive_confirm", + "session_batch_delete_worktree_confirm", + "session_batch_archive_worktree_confirm", + "session_select_mode", + "session_select_mode_desc", + "session_select_all", + "session_selected_count", + "session_no_selection", +) + + +@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"]) +def test_session_management_keys_present(loc_key: str): + """Every locale block must define all session-management keys (no fallback to English).""" + seg = _i18n_locale_block(loc_key) + missing = [k for k in SESSION_MANAGEMENT_KEYS if _value_of(seg, k) is None] + assert not missing, ( + f"Locale {loc_key!r} is missing session-management keys: {missing}. " + f"Add translations in static/i18n.js (issue #2112)." + ) From 265496782a6ad4a66e71840592464b49104de59f Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Tue, 12 May 2026 01:43:16 -0700 Subject: [PATCH 12/28] docs: clarify compression anchor helpers --- api/compression_anchor.py | 31 +++++++++++++++++++ api/profiles.py | 4 +-- api/streaming.py | 8 ++--- .../test_issue2024_env_lock_skill_imports.py | 8 ++--- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/api/compression_anchor.py b/api/compression_anchor.py index 98799efc..3a457d57 100644 --- a/api/compression_anchor.py +++ b/api/compression_anchor.py @@ -1,5 +1,36 @@ """ Shared helpers for session compression anchor metadata. + +Manual compression anchoring versus automatic compression paths +=============================================================== + +When ``auto_compression=True`` is passed to ``visible_messages_for_anchor()``, +the function accepts a broader set of message content types (including +provider-style ``input_text`` / ``output_text`` parts) and metadata markers +(``reasoning``, ``thinking``, etc.) from any non-tool role. This enables the +streaming auto-compression path to determine which messages should anchor +compression UI metadata without being limited to the legacy manual-compression +rules. + +When ``auto_compression=False`` (the default), the function applies the +historical manual-compression rules: only plain ``text`` content parts from +non-assistant roles are counted. + +Why this module exists +====================== + +Compression anchoring needs to identify which messages in a session transcript +are semantically significant enough to seed the compression UI metadata (e.g., +message count, token budget display). The original implementation hard-coded +these rules in multiple places. This module consolidates the logic so that: + +1. Manual compression anchoring (CLI/legacy path) uses the stricter ruleset. +2. Automatic compression (streaming/agent path) can leverage the relaxed ruleset + when it knows it is handling provider-style messages. + +Callers specify ``auto_compression=True`` when the messages may originate from +an automatic/compression-aware pipeline, and ``False`` (default) for manual +compression contexts. """ diff --git a/api/profiles.py b/api/profiles.py index 8f02bd16..bebd8201 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -41,7 +41,7 @@ _tls = threading.local() _SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool") -def _patch_skill_home_modules(home: Path) -> None: +def patch_skill_home_modules(home: Path) -> None: """Patch imported skill modules that cache HERMES_HOME at import time.""" for module_name in _SKILL_HOME_MODULES: module = sys.modules.get(module_name) @@ -628,7 +628,7 @@ def _set_hermes_home(home: Path): """Set HERMES_HOME env var and monkey-patch cached module-level paths.""" os.environ['HERMES_HOME'] = str(home) - _patch_skill_home_modules(home) + patch_skill_home_modules(home) # Patch cron/jobs module-level cache try: diff --git a/api/streaming.py b/api/streaming.py index fd772414..d60c4880 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2286,7 +2286,7 @@ def _run_agent_streaming( # process-level active-profile global. Falls back gracefully. try: from api.profiles import ( - _patch_skill_home_modules, + patch_skill_home_modules, get_hermes_home_for_profile, get_profile_runtime_env, ) @@ -2296,7 +2296,7 @@ def _run_agent_streaming( except ImportError: _profile_home = os.environ.get('HERMES_HOME', '') _profile_runtime_env = {} - _patch_skill_home_modules = None + patch_skill_home_modules = None # Capture the resolved profile name now, while profile context is # reliable. Used in the compression migration block to stamp s.profile @@ -2349,8 +2349,8 @@ def _run_agent_streaming( # above, so we only do lightweight sys.modules lookups and # attribute assignments here — no first-time import under # the lock (#2024). - if _patch_skill_home_modules is not None: - _patch_skill_home_modules(Path(_profile_home)) + if patch_skill_home_modules is not None: + patch_skill_home_modules(Path(_profile_home)) # Lock released — agent runs without holding it # ── MCP Server Discovery (lazy import, idempotent) ── # MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()` diff --git a/tests/test_issue2024_env_lock_skill_imports.py b/tests/test_issue2024_env_lock_skill_imports.py index b2a5c952..9f7a5819 100644 --- a/tests/test_issue2024_env_lock_skill_imports.py +++ b/tests/test_issue2024_env_lock_skill_imports.py @@ -165,7 +165,7 @@ class TestSysModulesLookupInEnvLock: lock_lines.append(line) lock_source = "\n".join(lock_lines) - assert "_patch_skill_home_modules" in lock_source, ( + assert "patch_skill_home_modules" in lock_source, ( "Inside `_ENV_LOCK`, streaming must use the shared skill module " "cache patch helper instead of duplicating module-specific logic " "(#2023/#2024)" @@ -179,15 +179,15 @@ class TestSysModulesLookupInEnvLock: node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) - and node.name == "_patch_skill_home_modules" + and node.name == "patch_skill_home_modules" ), None, ) - assert helper is not None, "_patch_skill_home_modules() must be defined" + assert helper is not None, "patch_skill_home_modules() must be defined" helper_source = ast.get_source_segment(source, helper) or "" assert "sys.modules.get" in helper_source, ( - "_patch_skill_home_modules() must use sys.modules.get(), not import, " + "patch_skill_home_modules() must use sys.modules.get(), not import, " "so env-lock callers do not trigger first-time imports (#2024)" ) assert "HERMES_HOME" in helper_source From 8fa92c680f8e07d3bbc9c0f9d31ceccc8d87805d Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 12 May 2026 17:32:05 +0800 Subject: [PATCH 13/28] Fix manual compression proxy timeouts --- CHANGELOG.md | 2 + api/routes.py | 193 ++++++++++++++++++++++++ static/commands.js | 183 ++++++++++++++++------- static/sessions.js | 1 + tests/test_sprint46.py | 331 +++++++++++++++++++++++++++++++++++++---- 5 files changed, 627 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6115c51f..4e131691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **PR #2128** by @franksong2702 — Manual `/compress` from the WebUI now uses an async start/status flow instead of holding one browser request open for the full provider compression call. `POST /api/session/compress/start` starts or reuses a session-keyed in-process compression job, `GET /api/session/compress/status?session_id=...` polls the result, and the existing synchronous `POST /api/session/compress` endpoint remains for compatibility. The frontend `/compress` command now starts the job, polls until completion, resumes polling when an already-open session is reloaded, and applies the saved compressed session when ready. This avoids reverse-proxy timeout splits where the browser reports failure while the server continues and later saves the compressed transcript. + - **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh. ## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback) diff --git a/api/routes.py b/api/routes.py index efae299c..463ef4b0 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5,6 +5,7 @@ Extracted from server.py (Sprint 11) so server.py is a thin shell. import html as _html import copy +import io import json import logging import os @@ -49,6 +50,9 @@ _CLIENT_DISCONNECT_ERRORS = ( # Track job IDs currently being executed so the frontend can poll status. _RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp _RUNNING_CRON_LOCK = threading.Lock() +_MANUAL_COMPRESSION_JOBS: dict[str, dict] = {} +_MANUAL_COMPRESSION_JOBS_LOCK = threading.Lock() +_MANUAL_COMPRESSION_JOB_TTL_SECONDS = 10 * 60 _CRON_OUTPUT_CONTENT_LIMIT = 8000 _CRON_OUTPUT_HEADER_CONTEXT = 200 _MESSAGING_RAW_SOURCES = {str(s).strip().lower() for s in MESSAGING_SOURCES} @@ -3072,6 +3076,10 @@ def handle_get(handler, parsed) -> bool: 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() @@ -4421,6 +4429,9 @@ def handle_post(handler, parsed) -> bool: "parent_session_id": source.session_id, }) + if parsed.path == "/api/session/compress/start": + return _handle_session_compress_start(handler, body) + if parsed.path == "/api/session/compress": return _handle_session_compress(handler, body) @@ -7669,6 +7680,174 @@ def _handle_clarify_respond(handler, body): return j(handler, {"ok": True, "response": response}) +class _ManualCompressionMemoryHandler: + def __init__(self): + self.wfile = io.BytesIO() + 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 payload(self): + raw = self.wfile.getvalue().decode("utf-8") + return json.loads(raw) if raw else {} + + +def _manual_compression_cleanup_locked(now=None): + now = time.time() if now is None else now + for sid, job in list(_MANUAL_COMPRESSION_JOBS.items()): + if job.get("status") == "running": + continue + updated_at = float(job.get("updated_at") or job.get("started_at") or now) + if now - updated_at > _MANUAL_COMPRESSION_JOB_TTL_SECONDS: + _MANUAL_COMPRESSION_JOBS.pop(sid, None) + + +def _manual_compression_status_payload(job): + status = job.get("status") or "running" + payload = { + "ok": status not in {"error", "cancelled"}, + "status": status, + "session_id": job.get("session_id"), + "focus_topic": job.get("focus_topic"), + "started_at": job.get("started_at"), + "updated_at": job.get("updated_at"), + } + if status == "done": + result = job.get("result") + if isinstance(result, dict): + payload.update(result) + payload["status"] = "done" + payload["ok"] = True + elif status == "error": + payload["ok"] = False + payload["error"] = job.get("error") or "Compression failed" + payload["error_status"] = int(job.get("error_status") or 400) + elif status == "cancelled": + payload["ok"] = False + payload["error"] = job.get("error") or "Compression cancelled" + payload["error_status"] = int(job.get("error_status") or 409) + return payload + + +def _run_manual_compression_job(sid, body): + memory_handler = _ManualCompressionMemoryHandler() + try: + _handle_session_compress(memory_handler, body) + status = int(memory_handler.status or 500) + payload = memory_handler.payload() + with _MANUAL_COMPRESSION_JOBS_LOCK: + job = _MANUAL_COMPRESSION_JOBS.get(sid) + if not job: + return + now = time.time() + if status >= 400 or not isinstance(payload, dict) or payload.get("error"): + job.update( + { + "status": "error", + "error": str((payload or {}).get("error") or "Compression failed"), + "error_status": status, + "updated_at": now, + } + ) + else: + job.update( + { + "status": "done", + "result": payload, + "updated_at": now, + } + ) + except Exception as exc: + logger.warning("Manual compression worker failed for session %s: %s", sid, exc) + with _MANUAL_COMPRESSION_JOBS_LOCK: + job = _MANUAL_COMPRESSION_JOBS.get(sid) + if job: + job.update( + { + "status": "error", + "error": f"Compression failed: {_sanitize_error(exc)}", + "error_status": 500, + "updated_at": time.time(), + } + ) + + +def _handle_session_compress_start(handler, body): + try: + require(body, "session_id") + except ValueError as e: + return bad(handler, str(e)) + + sid = str(body.get("session_id") or "").strip() + if not sid: + return bad(handler, "session_id is required") + try: + s = get_session(sid) + except KeyError: + return bad(handler, "Session not found", 404) + if getattr(s, "active_stream_id", None): + return bad(handler, "Session is still streaming; wait for the current turn to finish.", 409) + + focus_topic = str(body.get("focus_topic") or body.get("topic") or "").strip()[:500] or None + job_body = {"session_id": sid} + if focus_topic: + job_body["focus_topic"] = focus_topic + + now = time.time() + with _MANUAL_COMPRESSION_JOBS_LOCK: + _manual_compression_cleanup_locked(now) + existing = _MANUAL_COMPRESSION_JOBS.get(sid) + if existing: + existing_payload = _manual_compression_status_payload(existing) + if existing_payload.get("status") == "running": + return j(handler, existing_payload) + _MANUAL_COMPRESSION_JOBS.pop(sid, None) + if existing_payload.get("status") == "done": + return j(handler, existing_payload) + job = { + "session_id": sid, + "focus_topic": focus_topic, + "status": "running", + "started_at": now, + "updated_at": now, + } + _MANUAL_COMPRESSION_JOBS[sid] = job + + worker = threading.Thread( + target=_run_manual_compression_job, + args=(sid, job_body), + name=f"manual-compress-{sid[:8]}", + daemon=True, + ) + worker.start() + + with _MANUAL_COMPRESSION_JOBS_LOCK: + return j(handler, _manual_compression_status_payload(_MANUAL_COMPRESSION_JOBS.get(sid, job))) + + +def _handle_session_compress_status(handler, sid): + sid = str(sid or "").strip() + if not sid: + return bad(handler, "session_id is required") + with _MANUAL_COMPRESSION_JOBS_LOCK: + _manual_compression_cleanup_locked() + job = _MANUAL_COMPRESSION_JOBS.get(sid) + if not job: + return j(handler, {"ok": True, "status": "idle", "session_id": sid}) + payload = _manual_compression_status_payload(job) + if payload.get("status") == "done": + _MANUAL_COMPRESSION_JOBS.pop(sid, None) + return j(handler, payload) + + def _handle_session_compress(handler, body): def _anchor_message_key(m): if not isinstance(m, dict): @@ -7867,6 +8046,12 @@ def _handle_session_compress(handler, body): # Lock contract: hold for the in-memory mutation only, never across # network I/O. original_messages = list(messages) + original_stream_state = ( + getattr(s, "active_stream_id", None), + getattr(s, "pending_user_message", None), + copy.deepcopy(getattr(s, "pending_attachments", None)), + getattr(s, "pending_started_at", None), + ) approx_tokens = _estimate_messages_tokens_rough(original_messages) agent = _run_agent.AIAgent( @@ -7898,6 +8083,14 @@ def _handle_session_compress(handler, body): with _cfg._get_session_agent_lock(sid): # Re-read messages to detect concurrent edits during the LLM call. # If the history changed, the compression result is stale — abort. + current_stream_state = ( + getattr(s, "active_stream_id", None), + getattr(s, "pending_user_message", None), + copy.deepcopy(getattr(s, "pending_attachments", None)), + getattr(s, "pending_started_at", None), + ) + if current_stream_state != original_stream_state: + return bad(handler, "Session stream state changed during compression; please retry.", 409) if _sanitize_messages_for_api(s.messages) != original_messages: return bad(handler, "Session was modified during compression; please retry.", 409) diff --git a/static/commands.js b/static/commands.js index bd2f146f..c09cbf54 100644 --- a/static/commands.js +++ b/static/commands.js @@ -382,6 +382,131 @@ async function cmdNew(){ showToast(t('new_session')); } +function _manualCompressionVisibleMessages(){ + return (S.messages||[]).filter(m=>{ + if(!m||!m.role||m.role==='tool') return false; + if(m.role==='assistant'){ + const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; + const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); + if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true; + } + return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length; + }); +} + +function _manualCompressionSleep(ms){ + return new Promise(resolve=>setTimeout(resolve, ms)); +} + +async function _pollManualCompressionResult(sid){ + let delay=700; + while(true){ + const data=await api(`/api/session/compress/status?session_id=${encodeURIComponent(sid)}`); + if(data&&data.status==='done') return data; + if(data&&data.status==='error'){ + const err=new Error(data.error||'Compression failed'); + err.status=data.error_status||400; + throw err; + } + if(data&&data.status==='idle') throw new Error('Compression job is no longer available'); + await _manualCompressionSleep(delay); + delay=Math.min(2000, delay+300); + } +} + +async function _applyManualCompressionResult(data, focusTopic, visibleCount, commandText){ + if(data&&data.session){ + const currentSid=S.session&&S.session.session_id; + if(data.session.session_id&&data.session.session_id!==currentSid){ + await loadSession(data.session.session_id); + }else{ + S.session=data.session; + S.messages=data.session.messages||[]; + S.toolCalls=data.session.tool_calls||[]; + clearLiveToolCards(); + localStorage.setItem('hermes-webui-session',S.session.session_id); + if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); + syncTopbar(); + renderMessages(); + await renderSessionList(); + updateQueueBadge(S.session.session_id); + } + } + const summary=data&&data.summary; + if(typeof setCompressionUi==='function'&&S.session){ + const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m)); + const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):''; + const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : ''; + // Prefer the persisted compaction handoff when it already exists in session state. + // The short summary fallback is only for environments where that message is unavailable. + const referenceText=messageRef || summaryRef; + const effectiveFocus=(data&&data.focus_topic)||focusTopic||''; + setCompressionUi({ + sessionId:S.session.session_id, + phase:'done', + focusTopic:effectiveFocus, + commandText:effectiveFocus?`/compress ${effectiveFocus}`:(commandText||'/compress'), + beforeCount:visibleCount, + summary:summary||null, + referenceText, + anchorVisibleIdx: data?.session?.compression_anchor_visible_idx, + anchorMessageKey: data?.session?.compression_anchor_message_key||null, + }); + } + if(typeof setComposerStatus==='function') setComposerStatus(''); + renderMessages(); + if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); +} + +async function resumeManualCompressionForSession(sid){ + if(!sid) return; + try{ + const status=await api(`/api/session/compress/status?session_id=${encodeURIComponent(sid)}`); + if(!status||status.status!=='running') return; + const visibleMessages=_manualCompressionVisibleMessages(); + const visibleCount=visibleMessages.length; + const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null); + if(typeof setBusy==='function') setBusy(true); + if(typeof setComposerStatus==='function') setComposerStatus(t('compressing')); + if(typeof setCompressionUi==='function'){ + setCompressionUi({ + sessionId:sid, + phase:'running', + focusTopic:status.focus_topic||'', + commandText:status.focus_topic?`/compress ${status.focus_topic}`:'/compress', + beforeCount:visibleCount, + anchorVisibleIdx:Math.max(0, visibleCount-1), + anchorMessageKey, + }); + } + renderMessages(); + const done=await _pollManualCompressionResult(sid); + if(!S.session||S.session.session_id!==sid) return; + await _applyManualCompressionResult(done, status.focus_topic||'', visibleCount, status.focus_topic?`/compress ${status.focus_topic}`:'/compress'); + }catch(e){ + if(S.session&&S.session.session_id===sid&&typeof setCompressionUi==='function'){ + const visibleMessages=_manualCompressionVisibleMessages(); + setCompressionUi({ + sessionId:sid, + phase:'error', + focusTopic:'', + commandText:'/compress', + beforeCount:visibleMessages.length, + errorText:`Compression failed: ${e.message}`, + anchorVisibleIdx:Math.max(0, visibleMessages.length-1), + anchorMessageKey:null, + }); + renderMessages(); + } + }finally{ + if(S.session&&S.session.session_id===sid){ + if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); + if(typeof setBusy==='function') setBusy(false); + if(typeof setComposerStatus==='function') setComposerStatus(''); + } + } +} + async function _runManualCompression(focusTopic){ if(!S.session){showToast(t('no_active_session'));return;} let visibleCount=0; @@ -410,15 +535,7 @@ async function _runManualCompression(focusTopic){ if(typeof setBusy==='function') setBusy(true); const body={session_id:sid}; if(focusTopic) body.focus_topic=focusTopic; - const visibleMessages=(S.messages||[]).filter(m=>{ - if(!m||!m.role||m.role==='tool') return false; - if(m.role==='assistant'){ - const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; - const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); - if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true; - } - return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length; - }); + const visibleMessages=_manualCompressionVisibleMessages(); visibleCount=visibleMessages.length; const anchorVisibleIdx=Math.max(0, visibleCount - 1); const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null); @@ -436,48 +553,14 @@ async function _runManualCompression(focusTopic){ } if(typeof setComposerStatus==='function') setComposerStatus(t('compressing')); renderMessages(); - const data=await api('/api/session/compress',{method:'POST',body:JSON.stringify(body)}); - if(data&&data.session){ - const currentSid=S.session&&S.session.session_id; - if(data.session.session_id&&data.session.session_id!==currentSid){ - await loadSession(data.session.session_id); - }else{ - S.session=data.session; - S.messages=data.session.messages||[]; - S.toolCalls=data.session.tool_calls||[]; - clearLiveToolCards(); - localStorage.setItem('hermes-webui-session',S.session.session_id); - if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); - syncTopbar(); - renderMessages(); - await renderSessionList(); - updateQueueBadge(S.session.session_id); - } + const started=await api('/api/session/compress/start',{method:'POST',body:JSON.stringify(body)}); + if(started&&started.status==='error'){ + const err=new Error(started.error||'Compression failed'); + err.status=started.error_status||400; + throw err; } - const summary=data&&data.summary; - if(typeof setCompressionUi==='function'&&S.session){ - const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m)); - const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):''; - const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : ''; - // Prefer the persisted compaction handoff when it already exists in session state. - // The short summary fallback is only for environments where that message is unavailable. - const referenceText=messageRef || summaryRef; - const effectiveFocus=(data&&data.focus_topic)||focusTopic||''; - setCompressionUi({ - sessionId:S.session.session_id, - phase:'done', - focusTopic:effectiveFocus, - commandText:effectiveFocus?`/compress ${effectiveFocus}`:'/compress', - beforeCount:visibleCount, - summary:summary||null, - referenceText, - anchorVisibleIdx: data?.session?.compression_anchor_visible_idx, - anchorMessageKey: data?.session?.compression_anchor_message_key||null, - }); - } - if(typeof setComposerStatus==='function') setComposerStatus(''); - renderMessages(); - if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); + const data=(started&&started.status==='done')?started:await _pollManualCompressionResult(sid); + await _applyManualCompressionResult(data, focusTopic, visibleCount, commandText); }catch(e){ if(typeof setCompressionUi==='function'){ const currentSid=S.session&&S.session.session_id; diff --git a/static/sessions.js b/static/sessions.js index 89d7e792..bdebbff2 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -641,6 +641,7 @@ async function loadSession(sid){ setComposerStatus(''); 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('.'); diff --git a/tests/test_sprint46.py b/tests/test_sprint46.py index 35145c95..e502ea9b 100644 --- a/tests/test_sprint46.py +++ b/tests/test_sprint46.py @@ -6,6 +6,8 @@ import contextlib import io import json import sys +import threading +import time import types from api.models import Session @@ -59,39 +61,9 @@ class _FakeAgent: _FakeAgent.last_instance = self -def _make_session(messages=None): - SESSION_DIR.mkdir(parents=True, exist_ok=True) - messages = messages or [ - {"role": "user", "content": "one"}, - {"role": "assistant", "content": "two"}, - {"role": "user", "content": "three"}, - {"role": "assistant", "content": "four"}, - ] - s = Session( - session_id="compress_test_001", - title="Untitled", - workspace="/tmp/hermes-webui-test", - model="openai/gpt-5.4-mini", - messages=messages, - ) - s.save(touch_updated_at=False) - return s.session_id - - -def test_session_compress_requires_session_id(cleanup_test_sessions): - handler = _FakeHandler() - _handle_session_compress(handler, {}) - assert handler.status == 400 - assert handler.payload()["error"] == "Missing required field(s): session_id" - - -def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions): - created = cleanup_test_sessions - sid = _make_session() - created.append(sid) - +def _install_fake_compression_runtime(monkeypatch, agent_cls): fake_run_agent = types.ModuleType("run_agent") - fake_run_agent.AIAgent = _FakeAgent + fake_run_agent.AIAgent = agent_cls monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) import api.config as _cfg @@ -128,6 +100,40 @@ def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions): }, ) + +def _make_session(messages=None): + SESSION_DIR.mkdir(parents=True, exist_ok=True) + messages = messages or [ + {"role": "user", "content": "one"}, + {"role": "assistant", "content": "two"}, + {"role": "user", "content": "three"}, + {"role": "assistant", "content": "four"}, + ] + s = Session( + session_id=f"compress_test_{time.time_ns()}", + title="Untitled", + workspace="/tmp/hermes-webui-test", + model="openai/gpt-5.4-mini", + messages=messages, + ) + s.save(touch_updated_at=False) + return s.session_id + + +def test_session_compress_requires_session_id(cleanup_test_sessions): + handler = _FakeHandler() + _handle_session_compress(handler, {}) + assert handler.status == 400 + assert handler.payload()["error"] == "Missing required field(s): session_id" + + +def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions): + created = cleanup_test_sessions + sid = _make_session() + created.append(sid) + + _install_fake_compression_runtime(monkeypatch, _FakeAgent) + handler = _FakeHandler() _handle_session_compress(handler, {"session_id": sid, "focus_topic": "database schema"}) @@ -153,6 +159,253 @@ def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions): assert _FakeAgent.last_instance.context_compressor.calls[0]["focus_topic"] == "database schema" +def test_session_compress_start_is_async_and_reuses_running_job(monkeypatch, cleanup_test_sessions): + import api.routes as routes + + assert hasattr(routes, "_handle_session_compress_start") + assert hasattr(routes, "_handle_session_compress_status") + + class BlockingCompressor: + entered = threading.Event() + release = threading.Event() + calls = [] + + def compress(self, messages, current_tokens=None, focus_topic=None): + self.calls.append({"messages": list(messages), "focus_topic": focus_topic}) + self.entered.set() + assert self.release.wait(timeout=5), "test timed out waiting to release compression" + return [messages[0], messages[-1]] + + class BlockingAgent: + instances = [] + + def __init__(self, **kwargs): + self.context_compressor = BlockingCompressor() + self.instances.append(self) + + created = cleanup_test_sessions + sid = _make_session() + created.append(sid) + _install_fake_compression_runtime(monkeypatch, BlockingAgent) + try: + first = _FakeHandler() + routes._handle_session_compress_start(first, {"session_id": sid, "focus_topic": "slow"}) + assert first.status == 200 + first_payload = first.payload() + assert first_payload["ok"] is True + assert first_payload["status"] == "running" + assert first_payload["session_id"] == sid + assert first_payload["focus_topic"] == "slow" + assert BlockingCompressor.entered.wait(timeout=2) + + second = _FakeHandler() + routes._handle_session_compress_start(second, {"session_id": sid, "focus_topic": "slow"}) + assert second.status == 200 + second_payload = second.payload() + assert second_payload["status"] == "running" + assert len(BlockingAgent.instances) == 1 + + running = _FakeHandler() + routes._handle_session_compress_status(running, sid) + assert running.status == 200 + assert running.payload()["status"] == "running" + finally: + BlockingCompressor.release.set() + + deadline = time.time() + 5 + done_payload = None + while time.time() < deadline: + done = _FakeHandler() + routes._handle_session_compress_status(done, sid) + payload = done.payload() + if payload["status"] == "done": + done_payload = payload + break + time.sleep(0.02) + assert done_payload is not None + assert done_payload["summary"]["headline"] == "Compressed: 4 → 2 messages" + assert done_payload["session"]["messages"] == [ + {"role": "user", "content": "one"}, + {"role": "assistant", "content": "four"}, + ] + + +def test_session_compress_status_reports_worker_error_without_raw_paths(monkeypatch, cleanup_test_sessions): + import api.routes as routes + + assert hasattr(routes, "_handle_session_compress_start") + assert hasattr(routes, "_handle_session_compress_status") + + class FailingCompressor: + entered = threading.Event() + + def compress(self, messages, current_tokens=None, focus_topic=None): + self.entered.set() + raise RuntimeError("provider log at /Users/alice/.hermes/secrets/token.txt failed") + + class FailingAgent: + def __init__(self, **kwargs): + self.context_compressor = FailingCompressor() + + created = cleanup_test_sessions + sid = _make_session() + created.append(sid) + _install_fake_compression_runtime(monkeypatch, FailingAgent) + + start = _FakeHandler() + routes._handle_session_compress_start(start, {"session_id": sid}) + assert start.status == 200 + assert FailingCompressor.entered.wait(timeout=2) + + deadline = time.time() + 5 + error_payload = None + while time.time() < deadline: + status = _FakeHandler() + routes._handle_session_compress_status(status, sid) + payload = status.payload() + if payload["status"] == "error": + error_payload = payload + break + time.sleep(0.02) + assert error_payload is not None + assert error_payload["ok"] is False + assert error_payload["error_status"] == 400 + assert "" in error_payload["error"] + assert "/Users/alice" not in error_payload["error"] + + +def test_session_compress_start_retries_after_terminal_error(monkeypatch, cleanup_test_sessions): + import api.routes as routes + + class BlockingCompressor: + entered = threading.Event() + release = threading.Event() + + def compress(self, messages, current_tokens=None, focus_topic=None): + self.entered.set() + assert self.release.wait(timeout=5), "test timed out waiting to release compression" + return [messages[0], messages[-1]] + + class BlockingAgent: + def __init__(self, **kwargs): + self.context_compressor = BlockingCompressor() + + created = cleanup_test_sessions + sid = _make_session() + created.append(sid) + _install_fake_compression_runtime(monkeypatch, BlockingAgent) + + with routes._MANUAL_COMPRESSION_JOBS_LOCK: + routes._MANUAL_COMPRESSION_JOBS[sid] = { + "session_id": sid, + "focus_topic": None, + "status": "error", + "error": "previous failure", + "error_status": 400, + "started_at": time.time(), + "updated_at": time.time(), + } + + try: + retry = _FakeHandler() + routes._handle_session_compress_start(retry, {"session_id": sid}) + assert retry.status == 200 + retry_payload = retry.payload() + assert retry_payload["status"] == "running" + assert retry_payload["ok"] is True + assert BlockingCompressor.entered.wait(timeout=2) + finally: + BlockingCompressor.release.set() + + +def test_session_compress_async_reports_stale_session_guard(monkeypatch, cleanup_test_sessions): + import api.routes as routes + + created = cleanup_test_sessions + sid = _make_session() + created.append(sid) + + class MutatingCompressor: + entered = threading.Event() + + def compress(self, messages, current_tokens=None, focus_topic=None): + live = get_session(sid) + live.messages.append({"role": "user", "content": "concurrent edit"}) + self.entered.set() + return [messages[0], messages[-1]] + + class MutatingAgent: + def __init__(self, **kwargs): + self.context_compressor = MutatingCompressor() + + _install_fake_compression_runtime(monkeypatch, MutatingAgent) + + start = _FakeHandler() + routes._handle_session_compress_start(start, {"session_id": sid}) + assert start.status == 200 + assert MutatingCompressor.entered.wait(timeout=2) + + deadline = time.time() + 5 + error_payload = None + while time.time() < deadline: + status = _FakeHandler() + routes._handle_session_compress_status(status, sid) + payload = status.payload() + if payload["status"] == "error": + error_payload = payload + break + time.sleep(0.02) + assert error_payload is not None + assert error_payload["ok"] is False + assert error_payload["error_status"] == 409 + assert "modified during compression" in error_payload["error"] + assert get_session(sid).messages[-1]["content"] == "concurrent edit" + + +def test_session_compress_async_reports_stream_state_guard(monkeypatch, cleanup_test_sessions): + import api.routes as routes + + created = cleanup_test_sessions + sid = _make_session() + created.append(sid) + + class StreamMutatingCompressor: + entered = threading.Event() + + def compress(self, messages, current_tokens=None, focus_topic=None): + live = get_session(sid) + live.active_stream_id = "stream-concurrent" + self.entered.set() + return [messages[0], messages[-1]] + + class StreamMutatingAgent: + def __init__(self, **kwargs): + self.context_compressor = StreamMutatingCompressor() + + _install_fake_compression_runtime(monkeypatch, StreamMutatingAgent) + + start = _FakeHandler() + routes._handle_session_compress_start(start, {"session_id": sid}) + assert start.status == 200 + assert StreamMutatingCompressor.entered.wait(timeout=2) + + deadline = time.time() + 5 + error_payload = None + while time.time() < deadline: + status = _FakeHandler() + routes._handle_session_compress_status(status, sid) + payload = status.payload() + if payload["status"] == "error": + error_payload = payload + break + time.sleep(0.02) + assert error_payload is not None + assert error_payload["ok"] is False + assert error_payload["error_status"] == 409 + assert "stream state changed" in error_payload["error"] + assert get_session(sid).active_stream_id == "stream-concurrent" + + def test_static_commands_js_registers_compress_alias(cleanup_test_sessions): from pathlib import Path @@ -160,7 +413,10 @@ def test_static_commands_js_registers_compress_alias(cleanup_test_sessions): src = f.read() assert "name:'compress'" in src assert "name:'compact'" in src - assert "/api/session/compress" in src + assert "/api/session/compress/start" in src + assert "/api/session/compress/status" in src + assert "await api('/api/session/compress'," not in src + assert "beforeCount:visibleCount" in src assert "cmdCompress" in src assert "cmdCompact" in src @@ -173,3 +429,12 @@ def test_static_commands_js_prefers_persisted_reference_message(cleanup_test_ses assert "const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';" in src assert "const referenceText=messageRef || summaryRef;" in src + + +def test_static_session_load_resumes_manual_compression_polling(cleanup_test_sessions): + from pathlib import Path + + with open(Path(__file__).resolve().parents[1] / "static" / "sessions.js", encoding="utf-8") as f: + src = f.read() + + assert "resumeManualCompressionForSession" in src From dd543e4175e66216bbf6145841c102be963a0ca3 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Tue, 12 May 2026 02:52:43 -0700 Subject: [PATCH 14/28] fix: purge missing inflight sessions --- static/sessions.js | 16 ++- tests/test_inflight_purge_missing_sessions.py | 124 ++++++++++++++++++ tests/test_issue2066_stale_sidebar_spinner.py | 13 +- 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 tests/test_inflight_purge_missing_sessions.py diff --git a/static/sessions.js b/static/sessions.js index 89d7e792..cafab5fa 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -246,7 +246,10 @@ function _isSessionEffectivelyStreaming(s) { function _purgeStaleInflightEntries() { // Clean up INFLIGHT entries for sessions the server confirms are NOT // streaming. This prevents the in-memory cache from growing unbounded - // when streams end abnormally. (#2066) + // when streams end abnormally. (#2066) Additionally, any INFLIGHT entry + // whose session id is no longer present in the current _allSessions list + // (deleted / archived / filtered out) is also removed so that ghost entries + // from deleted sessions do not accumulate. (#2092) if (typeof INFLIGHT !== 'object' || !INFLIGHT) return; const sessionsById = new Map(); if (Array.isArray(_allSessions)) { @@ -255,11 +258,20 @@ function _purgeStaleInflightEntries() { } } for (const sid of Object.keys(INFLIGHT)) { + if (!sessionsById.has(sid)) { + // Session is absent from _allSessions — it was deleted / archived / + // filtered and can never stream again, so drop the entry. + delete INFLIGHT[sid]; + if (typeof clearInflightState === 'function') clearInflightState(sid); + continue; + } const s = sessionsById.get(sid); - if (s && !s.is_streaming) { + if (!s.is_streaming) { + // Session exists but is not streaming — purge it. delete INFLIGHT[sid]; if (typeof clearInflightState === 'function') clearInflightState(sid); } + // Sessions that exist and are still streaming are preserved. } } diff --git a/tests/test_inflight_purge_missing_sessions.py b/tests/test_inflight_purge_missing_sessions.py new file mode 100644 index 00000000..a8dcb077 --- /dev/null +++ b/tests/test_inflight_purge_missing_sessions.py @@ -0,0 +1,124 @@ +# Regression tests for _purgeStaleInflightEntries ghost-entry leak (#2092). +# +# When a session is deleted / archived / filtered out of the sidebar list, +# _allSessions no longer contains it. Previously _purgeStaleInflightEntries() +# only deleted an INFLIGHT entry when the session WAS present and was not +# streaming, leaving ghost entries for absent sessions indefinitely. The fix +# adds an explicit check: if the sid is absent from _allSessions, the entry is +# always removed. +# +# These are source-level / parse-time regression tests using the same pattern +# as test_inflight_stream_reuse.py. They verify the function body contains the +# correct guard logic and would break if the fix regresses. +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +SESSIONS_JS = (REPO_ROOT / 'static' / 'sessions.js').read_text(encoding='utf-8') + + +def _function_body(src: str, name: str) -> str: + marker = f'function {name}(' + start = src.find(marker) + assert start != -1, f'{name}() not found in sessions.js' + # Find the opening { of the function body. After the ')' of the parameter + # list there may be whitespace (space, newline) before '{'. We handle both + # `){` and `) \n{` cases so this works whether or not the source uses a + # newline between the closing paren and the brace. + rparen = src.find(')', start) + assert rparen != -1, f'{name}() closing paren not found' + brace = src.find('{', rparen) + assert brace != -1, f'{name}() body brace not found' + depth = 1 + i = brace + 1 + while i < len(src) and depth: + if src[i] == '{': + depth += 1 + elif src[i] == '}': + depth -= 1 + i += 1 + assert depth == 0, f'{name}() body did not close' + return src[brace + 1:i - 1] + + +def test_purge_removes_entry_when_sid_is_absent_from_all_sessions(): + r'''An INFLIGHT entry whose sid is missing from _allSessions must be removed. + + The original bug: the loop condition was `if (s && !s.is_streaming)`. + When sid was absent, `sessionsById.get(sid)` returned undefined, + the `s &&` guard short-circuited, and no deletion occurred. + The fix adds an explicit `if (!sessionsById.has(sid))` branch before + the streaming check, so missing sessions are always purged. + ''' + body = _function_body(SESSIONS_JS, '_purgeStaleInflightEntries') + + # The function must check whether the sid exists in the sessions map. + assert 'sessionsById.has(sid)' in body, ( + '_purgeStaleInflightEntries() must check sessionsById.has(sid) ' + 'to catch sessions absent from _allSessions' + ) + + # There must be a branch that deletes INFLIGHT[sid] for missing sessions. + # It should appear before the `!s.is_streaming` check so that missing + # sessions are always cleaned regardless of their streaming state. + has_check_pos = body.find('sessionsById.has(sid)') + assert has_check_pos != -1 + + # The deletion for absent sessions must be unconditional (no !s.is_streaming guard). + # Walk forward from the has() check and verify delete appears without a streaming guard. + segment = body[has_check_pos:] + # Find the closing of the outer if block (the next unindented '}' or end of body). + # Simpler: check the first occurrence of 'delete INFLIGHT[sid]' after has() and + # verify the intervening code does NOT contain 'is_streaming' before that delete. + first_delete = segment.find('delete INFLIGHT[sid]') + assert first_delete != -1, 'No delete INFLIGHT[sid] found after sessionsById.has(sid)' + between = segment[:first_delete] + assert 'is_streaming' not in between, ( + 'delete INFLIGHT[sid] for absent sessions must not be guarded by is_streaming' + ) + + +def test_purge_removes_entry_when_sid_present_but_not_streaming(): + r'''An INFLIGHT entry for a session present in _allSessions with + is_streaming:false must also be removed (existing behaviour preserved). + ''' + body = _function_body(SESSIONS_JS, '_purgeStaleInflightEntries') + assert '!s.is_streaming' in body, ( + '_purgeStaleInflightEntries() must still check !s.is_streaming for ' + 'sessions present in _allSessions' + ) + # Verify the delete for the non-streaming case is present. + # The body should contain something like `if (!s.is_streaming) { delete INFLIGHT[sid]; ... }` + ns_pos = body.find('!s.is_streaming') + assert ns_pos != -1 + seg = body[ns_pos:] + delete_in_ns = seg.find('delete INFLIGHT[sid]') + assert delete_in_ns != -1, ( + 'delete INFLIGHT[sid] must follow !s.is_streaming for sessions not streaming' + ) + + +def test_purge_preserves_entry_when_sid_present_and_streaming(): + r'''An INFLIGHT entry for a session present in _allSessions with + is_streaming:true must NOT be deleted. + ''' + body = _function_body(SESSIONS_JS, '_purgeStaleInflightEntries') + + # The non-streaming branch must be an if without an else that deletes. + # If an else block deleted on streaming, the fix would be wrong. + # We verify by checking that the body does NOT contain a pattern like: + # `} else { delete INFLIGHT[sid]; }` immediately after an is_streaming check. + ns_pos = body.find('!s.is_streaming') + assert ns_pos != -1 + # The delete for non-streaming is in the same if block. + # We confirm that there is no unconditional delete outside the two guarded paths. + # Reconstruct the two guarded paths: + # 1. if (!sessionsById.has(sid)) { delete INFLIGHT[sid]; } + # 2. if (!s.is_streaming) { delete INFLIGHT[sid]; } + # After both, there should be no third unguarded delete. + + # Count 'delete INFLIGHT[sid]' — there should be exactly 2 (one per guarded path). + delete_count = body.count('delete INFLIGHT[sid]') + assert delete_count == 2, ( + f'Expected exactly 2 delete INFLIGHT[sid] statements (one per guarded path), ' + f'found {delete_count}. Streaming sessions must not be deleted.' + ) \ No newline at end of file diff --git a/tests/test_issue2066_stale_sidebar_spinner.py b/tests/test_issue2066_stale_sidebar_spinner.py index f1cf82c6..c838dc1c 100644 --- a/tests/test_issue2066_stale_sidebar_spinner.py +++ b/tests/test_issue2066_stale_sidebar_spinner.py @@ -33,7 +33,12 @@ def test_cache_render_purges_stale_non_streaming_inflight_entries(): assert "if (s && s.session_id) sessionsById.set(s.session_id, s);" in purge_block assert "const s = sessionsById.get(sid);" in purge_block assert "_allSessionsById" not in purge_block - assert "if (s && !s.is_streaming)" in purge_block + # Non-streaming sessions that ARE in _allSessions are purged (original #2066 + # semantics). Sessions absent from _allSessions are also purged (adds #2092 + # ghost-entry cleanup); the guard check for !sessionsById.has(sid) must come + # before the non-streaming check for code clarity and correctness. + assert "if (!sessionsById.has(sid))" in purge_block + assert "!s.is_streaming" in purge_block assert "delete INFLIGHT[sid];" in purge_block assert "clearInflightState(sid);" in purge_block assert "_purgeStaleInflightEntries();" in render_block @@ -62,10 +67,12 @@ console.log(JSON.stringify({{inflight: INFLIGHT, cleared}})); result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) payload = json.loads(result.stdout) + # With #2092, sessions absent from _allSessions (like `unknown-session`) + # are also purged and have clearInflightState called for them. `done-session` + # remains in _allSessions with is_streaming=false so it is still purged too. assert payload == { "inflight": { "running-session": True, - "unknown-session": True, }, - "cleared": ["done-session"], + "cleared": sorted(["unknown-session", "done-session"]), } From f1b2a21bd4e2b9b75e64a439529941332ae456ab Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Tue, 12 May 2026 18:02:49 +0800 Subject: [PATCH 15/28] feat: lazy-load full lineage segments --- static/sessions.js | 82 ++++++++++++++- tests/test_session_lineage_collapse.py | 132 +++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 3 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 89d7e792..4c0d821c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1272,6 +1272,9 @@ let _sessionActionAnchor = null; let _sessionActionSessionId = null; const _expandedChildSessionKeys = new Set(); const _expandedLineageKeys = new Set(); +const _lineageReportCache = new Map(); +const _lineageReportInflight = new Map(); +let _lineageReportCacheGeneration = 0; let _sessionVisibleSidebarIds = []; const SESSION_VIRTUAL_ROW_HEIGHT = 52; const SESSION_VIRTUAL_BUFFER_ROWS = 12; @@ -1758,6 +1761,7 @@ async function renderSessionList(){ // without a second round-trip. Stashed on the module for renderSessionListFromCache. _otherProfileCount = sessData.other_profile_count || 0; _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); + _clearLineageReportCache(); _allProjects = projData.projects||[]; // Capture server clock for clock-skew compensation (issue #1144). // server_time is epoch seconds from the server's time.time(). @@ -2095,6 +2099,73 @@ function _sessionSegmentCount(s){ return count>1?count:0; } +function _clearLineageReportCache(){ + _lineageReportCache.clear(); + _lineageReportInflight.clear(); + _lineageReportCacheGeneration++; +} + +function _lineageReportCacheKey(s,lineageKey){ + return lineageKey||_sidebarLineageKeyForRow(s)||null; +} + +function _lineageLocalSegmentCount(s){ + if(!s) return 0; + if(Array.isArray(s._lineage_segments)) return s._lineage_segments.length; + return s.session_id?1:0; +} + +function _lineageReportNeedsFetch(s,lineageKey,segmentCount){ + const key=_lineageReportCacheKey(s,lineageKey); + if(!s||!s.session_id||!key) return false; + if(_lineageReportCache.has(key)||_lineageReportInflight.has(key)) return false; + return Number(segmentCount||0)>_lineageLocalSegmentCount(s); +} + +function _lineageSegmentsForRender(s,lineageKey){ + const segments=[]; + const seen=new Set(); + const currentSid=s&&s.session_id; + const addSegment=(seg)=>{ + if(!seg||!seg.session_id||seg.session_id===currentSid||seen.has(seg.session_id)) return; + if(seg.role==='child_session') return; + seen.add(seg.session_id); + segments.push({...seg}); + }; + for(const seg of (Array.isArray(s&&s._lineage_segments)?s._lineage_segments:[])) addSegment(seg); + const cached=_lineageReportCache.get(_lineageReportCacheKey(s,lineageKey)); + if(cached&&Array.isArray(cached.segments)){ + for(const seg of cached.segments) addSegment(seg); + } + return segments; +} + +function _fetchLineageReportForRow(s,lineageKey){ + const key=_lineageReportCacheKey(s,lineageKey); + if(!s||!s.session_id||!key) return Promise.resolve(null); + if(_lineageReportCache.has(key)) return Promise.resolve(_lineageReportCache.get(key)); + if(_lineageReportInflight.has(key)) return _lineageReportInflight.get(key); + const generation=_lineageReportCacheGeneration; + let request; + request=api('/api/session/lineage/report?session_id='+encodeURIComponent(s.session_id)) + .then(report=>{ + if(generation===_lineageReportCacheGeneration){ + _lineageReportCache.set(key,(report&&report.found!==false)?report:{error:true}); + } + return report; + }) + .catch(err=>{ + console.warn('lineage report',err); + if(generation===_lineageReportCacheGeneration) _lineageReportCache.set(key,{error:true}); + return null; + }) + .finally(()=>{ + if(_lineageReportInflight.get(key)===request) _lineageReportInflight.delete(key); + }); + _lineageReportInflight.set(key,request); + return request; +} + function _sidebarLineageKeyForRow(s){ if(!s) return null; return s._lineage_key||s._lineage_root_id||s.lineage_root_id||s.parent_session_id||s.session_id||null; @@ -2703,8 +2774,10 @@ function renderSessionListFromCache(){ } const lineageKey=_sidebarLineageKeyForRow(s); const segmentCount=_sessionSegmentCount(s); - const lineageSegments=Array.isArray(s._lineage_segments)?s._lineage_segments.filter(seg=>seg&&seg.session_id&&seg.session_id!==s.session_id):[]; - const canExpandLineageSegments=Boolean(lineageKey&&segmentCount>1&&lineageSegments.length>0); + const lineageSegments=_lineageSegmentsForRender(s,lineageKey); + const needsLineageReport=_lineageReportNeedsFetch(s,lineageKey,segmentCount); + const lineageReportKey=_lineageReportCacheKey(s,lineageKey); + const canExpandLineageSegments=Boolean(lineageKey&&segmentCount>1&&(lineageSegments.length>0||needsLineageReport||_lineageReportInflight.has(lineageReportKey))); const lineageSegmentsExpanded=canExpandLineageSegments&&_expandedLineageKeys.has(lineageKey); if(segmentCount>0){ const segmentCountEl=document.createElement('span'); @@ -2721,7 +2794,10 @@ function renderSessionListFromCache(){ e.preventDefault(); e.stopPropagation(); if(_expandedLineageKeys.has(lineageKey)) _expandedLineageKeys.delete(lineageKey); - else _expandedLineageKeys.add(lineageKey); + else { + _expandedLineageKeys.add(lineageKey); + if(needsLineageReport) _fetchLineageReportForRow(s,lineageKey).then(()=>renderSessionListFromCache()); + } renderSessionListFromCache(); }; segmentCountEl.onclick=toggleLineageSegments; diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 0d14938a..fd9c7e76 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -338,11 +338,16 @@ def test_lineage_segment_expansion_static_contract(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") assert "const _expandedLineageKeys = new Set();" in js + assert "const _lineageReportCache = new Map();" in js + assert "const _lineageReportInflight = new Map();" in js assert "session-lineage-count,.session-lineage-segments,.session-lineage-segment" in js assert "segmentCountEl.setAttribute('aria-expanded'" in js assert "_expandedLineageKeys.has(lineageKey)" in js assert "_expandedLineageKeys.add(lineageKey)" in js assert "_expandedLineageKeys.delete(lineageKey)" in js + assert "_fetchLineageReportForRow(s,lineageKey).then" in js + assert "'/api/session/lineage/report?session_id='" in js + assert "encodeURIComponent(s.session_id)" in js assert "className='session-lineage-segments'" in js assert "className='session-lineage-segment'" in js assert "const segTitle=seg.title||t('session_lineage_segment_untitled');" in js @@ -354,6 +359,133 @@ def test_lineage_segment_expansion_static_contract(): assert ".session-lineage-segment{" in css +def test_lineage_report_fetch_is_needed_only_when_backend_count_exceeds_materialized_segments(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +const _lineageReportCache = new Map(); +const _lineageReportInflight = new Map(); +eval(extractFunc('_lineageReportCacheKey')); +eval(extractFunc('_lineageLocalSegmentCount')); +eval(extractFunc('_lineageReportNeedsFetch')); +const backendOnly = {{session_id:'tip', _lineage_key:'root', _compression_segment_count:25}}; +const localFull = {{ + session_id:'tip', + _lineage_key:'root', + _compression_segment_count:2, + _lineage_segments:[{{session_id:'tip'}}, {{session_id:'root'}}], +}}; +const before = _lineageReportNeedsFetch(backendOnly, 'root', 25); +_lineageReportCache.set('root', {{segments:[{{session_id:'tip'}}, {{session_id:'root'}}]}}); +const afterCache = _lineageReportNeedsFetch(backendOnly, 'root', 25); +const fullLocal = _lineageReportNeedsFetch(localFull, 'root', 2); +console.log(JSON.stringify({{before, afterCache, fullLocal}})); +""" + assert json.loads(_run_node(source)) == {"before": True, "afterCache": False, "fullLocal": False} + + +def test_cached_lineage_report_segments_merge_with_materialized_segments_without_duplicates_or_children(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +const _lineageReportCache = new Map(); +eval(extractFunc('_lineageReportCacheKey')); +eval(extractFunc('_lineageSegmentsForRender')); +_lineageReportCache.set('root', {{ + segments:[ + {{session_id:'tip', title:'Tip', role:'tip', started_at:30}}, + {{session_id:'root', title:'Root', role:'hidden_segment', started_at:20}}, + {{session_id:'older', title:'Older', role:'hidden_segment', started_at:10}}, + {{session_id:'child', title:'Child', role:'child_session', started_at:40}}, + ], + children:[{{session_id:'child', title:'Child', role:'child_session'}}], +}}); +const row = {{ + session_id:'tip', + _lineage_key:'root', + _lineage_segments:[{{session_id:'tip', title:'Tip'}}, {{session_id:'root', title:'Root'}}], +}}; +const segments = _lineageSegmentsForRender(row, 'root').map(seg => seg.session_id); +console.log(JSON.stringify(segments)); +""" + assert json.loads(_run_node(source)) == ["root", "older"] + + +def test_lineage_report_fetch_uses_endpoint_once_and_caches_result(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +const _lineageReportCache = new Map(); +const _lineageReportInflight = new Map(); +let _lineageReportCacheGeneration = 0; +const calls = []; +function api(path) {{ + calls.push(path); + return Promise.resolve({{found:true, segments:[{{session_id:'tip'}}, {{session_id:'root'}}]}}); +}} +eval(extractFunc('_lineageReportCacheKey')); +eval(extractFunc('_fetchLineageReportForRow')); +(async()=>{{ + const row = {{session_id:'tip', _lineage_key:'root'}}; + const [first, second] = await Promise.all([ + _fetchLineageReportForRow(row, 'root'), + _fetchLineageReportForRow(row, 'root'), + ]); + await _fetchLineageReportForRow(row, 'root'); + console.log(JSON.stringify({{ + calls, + cached:_lineageReportCache.has('root'), + same:first===second, + }})); +}})().catch(err=>{{console.error(err); process.exit(1);}}); +""" + result = json.loads(_run_node(source)) + assert result == { + "calls": ["/api/session/lineage/report?session_id=tip"], + "cached": True, + "same": True, + } + + def test_active_hidden_lineage_segment_auto_expands_parent(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f""" From 442f01bbca4d89ac2553e8524f348eaacea6c27c Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Tue, 12 May 2026 04:11:14 -0700 Subject: [PATCH 16/28] docs: document turn journal fsync tradeoff --- docs/rfcs/turn-journal.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/rfcs/turn-journal.md b/docs/rfcs/turn-journal.md index 6c0924f4..9124b770 100644 --- a/docs/rfcs/turn-journal.md +++ b/docs/rfcs/turn-journal.md @@ -93,6 +93,43 @@ assistant_started -> interrupted 4. After the sidecar save that includes the assistant answer succeeds, append `completed`. 5. On cancellation or known worker exception, append `interrupted` with a reason. +## Synchronous durability design rationale + +The `submitted` event uses synchronous `fsync` on every write today. This is a deliberate tradeoff between latency and crash-safety guarantees: + +### Why synchronous for submitted events + +The `submitted` event is the durability anchor for the entire recovery story. If the server crashes before the worker starts, the journal must reflect that the user message was received. Async writes risk losing that guarantee: a crash shortly after a non-fsync'd write could leave the journal silent while `pending_user_message` still exists, creating ambiguity during recovery. The current design avoids that ambiguity at the cost of one extra disk round-trip per turn submission. + +### Latency expectations by storage type + +Reported fsync latency varies significantly across storage backends. Approximate qualitative ranges to keep in mind: + +- **SSD (NVM/NVMe)**: Single-digit milliseconds; p99 typically well under 10 ms on modern hardware. Most turn submissions will see sub-5 ms overhead. +- **Rotational disk (HDD)**: Seek time dominates; p50 ~5–15 ms, p99 can reach 50–100 ms under load. A busy server with many concurrent submissions may see queueing effects. +- **Docker/overlay filesystems**: fsync latency depends on the container storage driver and the backing host filesystem. Write-through and copy-on-write semantics can introduce additional overhead; p95 may be 10–50 ms in typical containerized deployments, though exact figures vary by configuration and host load. + +These ranges are order-of-magnitude guidance, not benchmarks. Exact figures depend on hardware, kernel version, filesystem mount options, and concurrent load. Do not commit specific millisecond claims to documentation without measured evidence. + +### Benchmark guidance for maintainers + +If evidence suggests the synchronous write is a bottleneck, measure before changing anything: + +1. Instrument the `append_turn_journal_event` helper to record wall-clock time for each event type (submitted, worker_started, etc.). +2. Capture p50/p95/p99 append/fsync latency over a representative workload (e.g., at least 1,000 submitted turns under realistic concurrency). +3. Isolate the fsync component: on Linux, use `strace -e fsync` or kernel tracing (`ftrace`, `perf`) to confirm where time is spent. +4. Check for patterns: if most submissions are under 5 ms but the p99 is 200 ms due to occasional disk contention, async writes help the tail but not the median. The tradeoff must be evaluated in context of your recovery guarantees. + +### Future follow-up: async lifecycle-event journaling + +Making journal writes asynchronous is a valid future optimization, but it requires: + +- A reliable flush strategy (e.g., time-bounded flush every N seconds, flush on session close, flush after K pending events). +- Recovery logic that handles partial flush windows: if a crash occurs before the flush, the last few submitted events may be missing from the journal. Recovery must account for that ambiguity. +- Tests that verify the flush correctness under crash injection. + +Async journal writes are **not** part of the initial implementation. They belong in a follow-up RFC once the synchronous baseline is proven stable and the recovery semantics are well-understood. + ## Startup recovery semantics On startup, for each journal file: From e03c197cdfbb1a5941aaec9cb9faddfb8ecaed74 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Tue, 12 May 2026 05:52:16 -0600 Subject: [PATCH 17/28] fix: recover from stale deleted workspaces --- api/routes.py | 41 ++++++++++++++- api/workspace.py | 34 ++++++++---- tests/test_workspace_stale_recovery.py | 72 ++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 tests/test_workspace_stale_recovery.py diff --git a/api/routes.py b/api/routes.py index efae299c..814db72f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6967,7 +6967,7 @@ def _handle_chat_start(handler, body, diag=None): attachments = _normalize_chat_attachments(body.get("attachments") or [])[:20] diag.stage("resolve_workspace") if diag else None try: - workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) + workspace = _resolve_chat_workspace_with_recovery(s, body.get("workspace")) except ValueError as e: return bad(handler, str(e)) requested_model = body.get("model") or s.model @@ -7000,6 +7000,45 @@ def _handle_chat_start(handler, body, diag=None): +def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str: + """Resolve a chat workspace, recovering stale implicit session paths. + + If the browser explicitly sent a workspace, preserve the existing strict + validation behaviour and surface any error to the user. + + If the browser omitted ``workspace`` and the session's stored workspace now + points at a deleted directory (common after old test workspaces are cleaned + up), fall back to the current last/default workspace and persist the repair + so the chat becomes usable again. + """ + explicit = requested_workspace not in (None, "") + candidate = requested_workspace if explicit else getattr(s, "workspace", None) + try: + return str(resolve_trusted_workspace(candidate)) + except ValueError: + if explicit: + raise + fallback = str(resolve_trusted_workspace(get_last_workspace())) + stale = str(candidate or "").strip() + if stale and fallback != stale: + logger.warning( + "Recovered stale session workspace for %s: %s -> %s", + getattr(s, "session_id", "unknown"), + stale, + fallback, + ) + s.workspace = fallback + try: + s.save() + except Exception: + logger.debug( + "Failed to persist recovered workspace for session %s", + getattr(s, "session_id", "unknown"), + ) + return fallback + raise + + def _normalize_chat_attachments(raw_attachments): """Normalize attachment payloads from the browser. diff --git a/api/workspace.py b/api/workspace.py index 5ec8ec9e..70226dd0 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -20,11 +20,26 @@ logger = logging.getLogger(__name__) from api.config import ( WORKSPACES_FILE as _GLOBAL_WS_FILE, LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE, - DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE, MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS ) +def _current_default_workspace() -> Path: + """Return the live default workspace from api.config. + + ``api.config.DEFAULT_WORKSPACE`` is mutable at runtime (for example after + ``save_settings()``). Importing it once into this module bakes in a stale + snapshot that can diverge from the actual current default and leak deleted + test workspaces back into live sessions. + """ + try: + from api import config as _config + + return Path(_config.DEFAULT_WORKSPACE).expanduser().resolve() + except Exception: + return Path.home().expanduser().resolve() + + # ── Profile-aware path resolution ─────────────────────────────────────────── def _profile_state_dir() -> Path: @@ -64,7 +79,7 @@ def _profile_default_workspace() -> str: 2. 'default_workspace' — alternate explicit key 3. 'terminal.cwd' — hermes-agent terminal working dir (most common) - Falls back to the boot-time DEFAULT_WORKSPACE constant. + Falls back to the live DEFAULT_WORKSPACE from api.config. """ try: from api.config import get_config @@ -86,7 +101,7 @@ def _profile_default_workspace() -> str: return str(p) except (ImportError, Exception): logger.debug("Failed to load profile default workspace config") - return str(_BOOT_DEFAULT_WORKSPACE) + return str(_current_default_workspace()) # ── Public API ────────────────────────────────────────────────────────────── @@ -427,7 +442,7 @@ def _trusted_workspace_roots() -> list[Path]: roots.append(p) add(Path.home()) - add(_BOOT_DEFAULT_WORKSPACE) + add(_current_default_workspace()) for w in load_workspaces(): add(w.get("path")) roots.sort(key=lambda p: len(str(p))) @@ -536,11 +551,10 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: /boot, /proc, /sys, /dev, /root on Linux/macOS; Windows system dirs). This prevents even admin-saved workspaces from pointing at OS internals. - None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always - trusted (it was validated at server startup). + None/empty path falls back to the current DEFAULT_WORKSPACE. """ if path in (None, ""): - return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve() + return _current_default_workspace() candidate = Path(path).expanduser().resolve() @@ -571,14 +585,14 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: except Exception: pass - # (C) Trusted if it is equal to or under the boot-time DEFAULT_WORKSPACE. + # (C) Trusted if it is equal to or under the current DEFAULT_WORKSPACE. # In Docker deployments HERMES_WEBUI_DEFAULT_WORKSPACE is often set to a # volume mount outside the user's home (e.g. /data/workspace). That path # was already validated at server startup, so any sub-path of it is safe # without requiring the user to add it to the workspace list manually. try: - boot_default = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve() - candidate.relative_to(boot_default) + current_default = _current_default_workspace() + candidate.relative_to(current_default) return candidate except ValueError: pass diff --git a/tests/test_workspace_stale_recovery.py b/tests/test_workspace_stale_recovery.py new file mode 100644 index 00000000..4206c434 --- /dev/null +++ b/tests/test_workspace_stale_recovery.py @@ -0,0 +1,72 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from api import config as api_config +from api import routes, workspace + + +def test_profile_default_workspace_uses_live_config_default(monkeypatch, tmp_path): + live_default = tmp_path / "live-default" + live_default.mkdir() + + monkeypatch.setattr(api_config, "DEFAULT_WORKSPACE", live_default) + monkeypatch.setattr(api_config, "get_config", lambda: {}) + + assert workspace._profile_default_workspace() == str(live_default.resolve()) + assert workspace.resolve_trusted_workspace(None) == live_default.resolve() + + +def test_resolve_chat_workspace_with_recovery_repairs_missing_implicit_workspace(monkeypatch, tmp_path): + fallback = tmp_path / "fallback" + fallback.mkdir() + stale = tmp_path / "deleted-workspace" + + def fake_resolve(value): + if value == str(stale): + raise ValueError(f"Path does not exist: {stale}") + return Path(value).resolve() + + saved = {"count": 0} + + def fake_save(): + saved["count"] += 1 + + session = SimpleNamespace(session_id="sess-1", workspace=str(stale), save=fake_save) + + monkeypatch.setattr(routes, "resolve_trusted_workspace", fake_resolve) + monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) + + resolved = routes._resolve_chat_workspace_with_recovery(session, None) + + assert resolved == str(fallback.resolve()) + assert session.workspace == str(fallback.resolve()) + assert saved["count"] == 1 + + +def test_resolve_chat_workspace_with_recovery_preserves_explicit_errors(monkeypatch, tmp_path): + fallback = tmp_path / "fallback" + fallback.mkdir() + stale = tmp_path / "deleted-workspace" + + def fake_resolve(value): + if value == str(stale): + raise ValueError(f"Path does not exist: {stale}") + return Path(value).resolve() + + saved = {"count": 0} + + def fake_save(): + saved["count"] += 1 + + session = SimpleNamespace(session_id="sess-2", workspace=str(fallback), save=fake_save) + + monkeypatch.setattr(routes, "resolve_trusted_workspace", fake_resolve) + monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) + + with pytest.raises(ValueError, match="Path does not exist"): + routes._resolve_chat_workspace_with_recovery(session, str(stale)) + + assert session.workspace == str(fallback) + assert saved["count"] == 0 From b7c5ba640cce3b611f653f5b719e77b6da9468e8 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 12 May 2026 20:01:22 +0800 Subject: [PATCH 18/28] Fix custom live model scoping --- CHANGELOG.md | 2 + api/routes.py | 82 ++++++++++++++++--- tests/test_byok_model_dropdown.py | 130 ++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6115c51f..0c6a77dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **PR #2135** by @franksong2702 — `/api/models/live?provider=custom:` now scopes custom-provider fallback models and direct `/v1/models` fetches to the requested named provider instead of leaking sibling `custom_providers` entries from the active profile. Bare `custom` only reads unnamed custom-provider entries, named custom providers also include `models` dict/list entries, and live fetch fallback uses the matched entry's `base_url` / `api_key` pair. + - **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh. ## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback) diff --git a/api/routes.py b/api/routes.py index efae299c..49442eca 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6104,27 +6104,85 @@ def _handle_live_models(handler, parsed): ids = [] if not ids: + custom_provider_entry = None + + def _custom_provider_entries_for_request(): + if not (provider == "custom" or provider.startswith("custom:")): + return [] + try: + from api.config import _custom_provider_slug_from_name + _cp_entries = cfg.get("custom_providers", []) + if not isinstance(_cp_entries, list): + return [] + _matches = [] + for _cp in _cp_entries: + if not isinstance(_cp, dict): + continue + _slug = _custom_provider_slug_from_name(_cp.get("name", "")) + if provider.startswith("custom:"): + if _slug == provider: + _matches.append(_cp) + elif provider == "custom" and not _slug: + _matches.append(_cp) + return _matches + except Exception: + return [] + + def _custom_provider_model_ids(_cp): + _ids = [] + + def _append(_mid): + _mid = str(_mid or "").strip() + if _mid and _mid not in _ids: + _ids.append(_mid) + + _append(_cp.get("model", "")) + _models = _cp.get("models") + if isinstance(_models, dict): + for _mid in _models: + if isinstance(_mid, str): + _append(_mid) + elif isinstance(_models, list): + for _item in _models: + if isinstance(_item, str): + _append(_item) + elif isinstance(_item, dict): + _append(_item.get("id") or _item.get("model") or _item.get("name")) + return _ids + + def _custom_provider_api_key(_cp): + _raw = _cp.get("api_key") + if _raw is not None: + _key = str(_raw).strip() + if _key.startswith("${") and _key.endswith("}") and len(_key) > 3: + _key = os.getenv(_key[2:-1], "").strip() + if _key: + return _key + _env = str(_cp.get("key_env") or "").strip() + return os.getenv(_env, "").strip() if _env else "" + # For 'custom' and 'custom:*' providers, provider_model_ids() # returns [] because they aren't real hermes_cli endpoints. # Fall back to the custom_providers entries from config.yaml so # the live-model enrichment step can add any models that weren't # already in the static list (issue #1619). if provider == "custom" or provider.startswith("custom:"): - try: - _cp_entries = cfg.get("custom_providers", []) - if isinstance(_cp_entries, list): - ids = [ - _cp.get("model", "") - for _cp in _cp_entries - if isinstance(_cp, dict) and _cp.get("model", "") - ] - except Exception: - pass + for _cp in _custom_provider_entries_for_request(): + if custom_provider_entry is None: + custom_provider_entry = _cp + ids.extend(_custom_provider_model_ids(_cp)) # If still no ids, try fetching from base_url directly (OpenAI-compat endpoint) if not ids and (provider == "custom" or provider.startswith("custom:")): - _base_url = cfg.get("model", {}).get("base_url") - _api_key = cfg.get("model", {}).get("api_key") + _base_url = None + _api_key = None + if custom_provider_entry: + _base_url = custom_provider_entry.get("base_url") + _api_key = _custom_provider_api_key(custom_provider_entry) + else: + _model_cfg = cfg.get("model", {}) + _base_url = _model_cfg.get("base_url") + _api_key = _model_cfg.get("api_key") if _base_url and _api_key: try: import urllib.request diff --git a/tests/test_byok_model_dropdown.py b/tests/test_byok_model_dropdown.py index 181d68f4..835d17c8 100644 --- a/tests/test_byok_model_dropdown.py +++ b/tests/test_byok_model_dropdown.py @@ -177,6 +177,32 @@ class TestLiveModelsCustomProviderFallback: """When provider='custom' and provider_model_ids() returns [], /api/models/live must fall back to custom_providers entries from config.yaml.""" + @staticmethod + def _install_provider_model_ids(monkeypatch, fn): + import types + + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.__path__ = [] + models = types.ModuleType("hermes_cli.models") + models.provider_model_ids = fn + monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.models", models) + + @staticmethod + def _call_live_models(monkeypatch, cfg, provider): + import api.config as c + import api.routes as r + + r._clear_live_models_cache() + monkeypatch.setattr(c, "get_config", lambda: cfg) + monkeypatch.setattr(c, "_resolve_provider_alias", lambda p: p) + monkeypatch.setattr(r, "j", lambda _handler, payload, **_kw: payload) + TestLiveModelsCustomProviderFallback._install_provider_model_ids(monkeypatch, lambda _p: []) + + parsed = mock.MagicMock() + parsed.query = f"provider={provider}" + return r._handle_live_models(object(), parsed) + def test_custom_fallback_code_present(self): src = read("api/routes.py") m = re.search( @@ -241,6 +267,110 @@ class TestLiveModelsCustomProviderFallback: f"got {model_ids}" ) + def test_named_custom_fallback_returns_only_matching_provider_models(self, monkeypatch): + """custom: must not leak sibling custom_providers models.""" + cfg = { + "model": {"provider": "custom:infini-ai"}, + "custom_providers": [ + { + "name": "rightcode-codex", + "model": "gpt-5.5", + "models": {"gpt-5.5-mini": {}}, + "base_url": "https://right.codes/codex/v1", + }, + { + "name": "infini-ai", + "model": "glm-5.1", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + }, + { + "name": "xiaomi-mimo", + "models": ["mimo-v2.5-pro"], + "base_url": "https://mimo.example.com/v1", + }, + ], + } + + resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex") + + assert resp["provider"] == "custom:rightcode-codex" + assert [m["id"] for m in resp["models"]] == ["gpt-5.5", "gpt-5.5-mini"] + + def test_bare_custom_fallback_ignores_named_custom_provider_models(self, monkeypatch): + """Bare custom only represents unnamed custom entries, not named siblings.""" + cfg = { + "model": {"provider": "custom"}, + "custom_providers": [ + {"name": "rightcode-codex", "model": "gpt-5.5"}, + {"name": "infini-ai", "model": "glm-5.1"}, + {"model": "unnamed-byok-model"}, + ], + } + + resp = self._call_live_models(monkeypatch, cfg, "custom") + + assert resp["provider"] == "custom" + assert [m["id"] for m in resp["models"]] == ["unnamed-byok-model"] + + def test_named_custom_live_fetch_uses_matching_entry_endpoint(self, monkeypatch): + """custom: live fetch must use that entry, not the active model config.""" + import json + import urllib.request + + requests = [] + + class Response: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps({"data": [{"id": "right-live-model"}]}).encode("utf-8") + + def fake_urlopen(req, timeout=None): + requests.append( + { + "url": req.full_url, + "authorization": req.headers.get("Authorization"), + "timeout": timeout, + } + ) + return Response() + + cfg = { + "model": { + "provider": "custom:infini-ai", + "base_url": "https://infini.example.com/v1", + "api_key": "infini-key", + }, + "custom_providers": [ + { + "name": "rightcode-codex", + "base_url": "https://right.codes/codex/v1", + "api_key": "right-key", + }, + { + "name": "infini-ai", + "base_url": "https://infini.example.com/v1", + "api_key": "infini-key", + }, + ], + } + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex") + + assert requests == [ + { + "url": "https://right.codes/codex/v1/models", + "authorization": "Bearer right-key", + "timeout": 8, + } + ] + assert [m["id"] for m in resp["models"]] == ["right-live-model"] + # ── Regression: known-good providers still work ─────────────────────────────── From b71822007745f03859853cf78da2d01f9820ad28 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 12 May 2026 20:08:51 +0800 Subject: [PATCH 19/28] Fix login health probe credentials --- CHANGELOG.md | 2 ++ static/login.js | 5 +++-- tests/test_1038_pwa_auth_redirect.py | 9 +++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6115c51f..6639e9ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **PR #2137** by @franksong2702 — The login page `/health` connectivity probe now sends same-origin credentials instead of forcing `credentials: "omit"`, so deployments protected by Cloudflare Access or another same-origin access proxy can pass their access cookie through before WebUI decides the service is reachable. This keeps the probe mount-relative and does not change WebUI password auth. + - **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh. ## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback) diff --git a/static/login.js b/static/login.js index 72c47a5b..28fdfe80 100644 --- a/static/login.js +++ b/static/login.js @@ -69,7 +69,8 @@ document.addEventListener('DOMContentLoaded', function () { // On page load, probe the server so we can distinguish "can't reach server" // (Tailscale off, wrong network) from "session expired / need to log in". - // Uses /health — a public endpoint, no auth required. + // Uses /health — public for WebUI auth, but deployment access proxies may + // require same-origin cookies before the request reaches WebUI. // If unreachable, retries every 3 s and auto-reloads once the server is back. (function checkConnectivity() { var retryTimer = null; @@ -81,7 +82,7 @@ document.addEventListener('DOMContentLoaded', function () { } function probe() { - fetch('health', { method: 'GET', credentials: 'omit' }) + fetch('health', { method: 'GET', credentials: 'same-origin' }) .then(function (r) { if (r.ok) { // Server is reachable — if we were in retry mode, reload so the diff --git a/tests/test_1038_pwa_auth_redirect.py b/tests/test_1038_pwa_auth_redirect.py index f5bbe98a..3608eb44 100644 --- a/tests/test_1038_pwa_auth_redirect.py +++ b/tests/test_1038_pwa_auth_redirect.py @@ -112,3 +112,12 @@ class TestLoginJsSafeNextPath: assert "charAt(0) !== '/'" in src or "startsWith('/')" in src, ( "_safeNextPath must reject non-path-absolute inputs (e.g. 'http://...')" ) + + def test_health_probe_sends_same_origin_credentials(self): + """Cloudflare Access protects /health with same-origin cookies before WebUI sees it.""" + src = self._login_js() + assert "fetch('health', { method: 'GET', credentials: 'omit' })" not in src, ( + "login.js must not omit credentials for the health probe because " + "deployment-level access proxies may require same-origin cookies" + ) + assert "fetch('health', { method: 'GET', credentials: 'same-origin' })" in src From f5f59a5813bbd4358ce4f3922041f611e3212c0d Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Tue, 12 May 2026 05:20:06 -0700 Subject: [PATCH 20/28] fix: audit turn journal terminal collisions --- api/session_recovery.py | 2 +- api/turn_journal.py | 33 +++++++++++++++++++++---- tests/test_turn_journal.py | 36 ++++++++++++++++++++++++++-- tests/test_turn_journal_lifecycle.py | 2 +- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/api/session_recovery.py b/api/session_recovery.py index 10f9d394..d00c6d68 100644 --- a/api/session_recovery.py +++ b/api/session_recovery.py @@ -499,7 +499,7 @@ def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None) for session_id in iter_turn_journal_session_ids(session_dir): journal = read_turn_journal(session_id, session_dir=session_dir) - states = derive_turn_journal_states(journal.get('events') or []) + states, _ = derive_turn_journal_states(journal.get('events') or []) live_path = session_dir / f"{session_id}.json" live_messages = _msg_count(live_path) existing_user_messages: set[str] = set() diff --git a/api/turn_journal.py b/api/turn_journal.py index f25268a4..628d2175 100644 --- a/api/turn_journal.py +++ b/api/turn_journal.py @@ -102,20 +102,43 @@ def read_turn_journal(session_id: str, *, session_dir: Path | None = None) -> di return {"session_id": str(session_id), "events": events, "malformed": malformed} -def derive_turn_journal_states(events: Iterable[dict]) -> dict[str, dict]: - """Return the latest event per ``turn_id``.""" +def derive_turn_journal_states(events: Iterable[dict]) -> tuple[dict[str, dict], list[dict]]: + '''Return the latest event per ``turn_id`` and any terminal-collision entries. + + The first element is the latest event per turn_id (same overwrite-by-timestamp + behaviour as before). The second element is a list of collision records, one + per turn_id that had more than one terminal event. Each collision record + contains ``turn_id`` and the ``events`` list (in ascending created_at order). + + A collision means the same logical turn recorded both ``completed`` and + ``interrupted`` terminal events -- the derived state still picks the latest + by timestamp, but callers can now detect and audit the double-terminal + situation explicitly rather than having it silently collapse. + ''' states: dict[str, dict] = {} + # Collect all terminal events per turn_id to detect collisions + terminal_events: dict[str, list[dict]] = {} for event in events: if not isinstance(event, dict): continue - turn_id = str(event.get("turn_id") or "").strip() + turn_id = str(event.get('turn_id') or '').strip() if not turn_id: continue + # Track terminal events for collision detection + if is_terminal_turn_event(event): + terminal_events.setdefault(turn_id, []).append(event) + # Existing latest-by-timestamp derivation previous = states.get(turn_id) - if previous is None or float(event.get("created_at") or 0) >= float(previous.get("created_at") or 0): + if previous is None or float(event.get('created_at') or 0) >= float(previous.get('created_at') or 0): states[turn_id] = event - return states + # Build collision list: turn_ids with more than one terminal event + collisions = [ + {'turn_id': tid, 'events': sorted(evts, key=lambda e: float(e.get('created_at') or 0))} + for tid, evts in terminal_events.items() + if len(evts) > 1 + ] + return states, collisions def _latest_turn_id_for_stream(events: Iterable[dict], stream_id: str) -> str | None: stream = str(stream_id or "").strip() diff --git a/tests/test_turn_journal.py b/tests/test_turn_journal.py index c5626358..bfefc407 100644 --- a/tests/test_turn_journal.py +++ b/tests/test_turn_journal.py @@ -58,7 +58,7 @@ def test_read_turn_journal_tolerates_malformed_lines(tmp_path): def test_derive_turn_journal_states_keeps_latest_event_per_turn(): - states = derive_turn_journal_states([ + states, _ = derive_turn_journal_states([ {"event": "submitted", "turn_id": "turn-1", "created_at": 1}, {"event": "worker_started", "turn_id": "turn-1", "created_at": 2}, {"event": "submitted", "turn_id": "turn-2", "created_at": 3}, @@ -70,7 +70,7 @@ def test_derive_turn_journal_states_keeps_latest_event_per_turn(): def test_derive_turn_journal_states_uses_created_at_not_file_order(): - states = derive_turn_journal_states([ + states, _ = derive_turn_journal_states([ {"event": "completed", "turn_id": "turn-1", "created_at": 20}, {"event": "submitted", "turn_id": "turn-1", "created_at": 10}, ]) @@ -133,3 +133,35 @@ def test_audit_ignores_completed_or_already_materialized_turn_journal_entry(tmp_ assert report["status"] == "ok" assert report["items"] == [] + + +def test_derive_turn_journal_states_reports_terminal_collision_when_both_completed_and_interrupted(): + # A turn that recorded both completed and interrupted terminal events should + # not silently collapse to one winner — the collision must be reported. + events = [ + {'event': 'submitted', 'turn_id': 'turn-double-terminal', 'created_at': 1}, + {'event': 'worker_started', 'turn_id': 'turn-double-terminal', 'created_at': 2}, + {'event': 'completed', 'turn_id': 'turn-double-terminal', 'created_at': 3}, + {'event': 'interrupted', 'turn_id': 'turn-double-terminal', 'created_at': 4, 'reason': 'server_restart'}, + ] + states, collisions = derive_turn_journal_states(events) + + # Derived state still picks the latest by timestamp (interrupted) + assert states['turn-double-terminal']['event'] == 'interrupted' + # But the collision is explicitly reported so callers can audit it + assert len(collisions) == 1 + assert collisions[0]['turn_id'] == 'turn-double-terminal' + assert [e['event'] for e in collisions[0]['events']] == ['completed', 'interrupted'] + + +def test_derive_turn_journal_states_no_collision_when_single_terminal(): + # A normal turn with only one terminal event must not produce a collision. + events = [ + {'event': 'submitted', 'turn_id': 'turn-normal', 'created_at': 1}, + {'event': 'worker_started', 'turn_id': 'turn-normal', 'created_at': 2}, + {'event': 'completed', 'turn_id': 'turn-normal', 'created_at': 3}, + ] + states, collisions = derive_turn_journal_states(events) + + assert states['turn-normal']['event'] == 'completed' + assert collisions == [] diff --git a/tests/test_turn_journal_lifecycle.py b/tests/test_turn_journal_lifecycle.py index ea7ae704..3f5a317a 100644 --- a/tests/test_turn_journal_lifecycle.py +++ b/tests/test_turn_journal_lifecycle.py @@ -21,7 +21,7 @@ def test_append_turn_journal_event_for_stream_reuses_submitted_turn_id(tmp_path) assert submitted["turn_id"] == "turn-1" assert worker["turn_id"] == "turn-1" - states = derive_turn_journal_states([submitted, worker]) + states, _ = derive_turn_journal_states([submitted, worker]) assert states["turn-1"]["event"] == "worker_started" From 516d942d6a816d0e9857d5cfe79f5559cf0ba005 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Tue, 12 May 2026 06:28:35 -0600 Subject: [PATCH 21/28] refactor: reduce stale workspace recovery fix --- api/routes.py | 37 ++++++-------------------- api/workspace.py | 37 ++++++++++---------------- tests/test_workspace_stale_recovery.py | 1 - 3 files changed, 22 insertions(+), 53 deletions(-) diff --git a/api/routes.py b/api/routes.py index 814db72f..b28bd2f4 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7001,16 +7001,7 @@ def _handle_chat_start(handler, body, diag=None): def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str: - """Resolve a chat workspace, recovering stale implicit session paths. - - If the browser explicitly sent a workspace, preserve the existing strict - validation behaviour and surface any error to the user. - - If the browser omitted ``workspace`` and the session's stored workspace now - points at a deleted directory (common after old test workspaces are cleaned - up), fall back to the current last/default workspace and persist the repair - so the chat becomes usable again. - """ + """Recover stale implicit session workspaces without hiding explicit errors.""" explicit = requested_workspace not in (None, "") candidate = requested_workspace if explicit else getattr(s, "workspace", None) try: @@ -7018,25 +7009,13 @@ def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str: except ValueError: if explicit: raise - fallback = str(resolve_trusted_workspace(get_last_workspace())) - stale = str(candidate or "").strip() - if stale and fallback != stale: - logger.warning( - "Recovered stale session workspace for %s: %s -> %s", - getattr(s, "session_id", "unknown"), - stale, - fallback, - ) - s.workspace = fallback - try: - s.save() - except Exception: - logger.debug( - "Failed to persist recovered workspace for session %s", - getattr(s, "session_id", "unknown"), - ) - return fallback - raise + fallback = str(resolve_trusted_workspace(get_last_workspace())) + s.workspace = fallback + try: + s.save() + except Exception: + pass + return fallback def _normalize_chat_attachments(raw_attachments): diff --git a/api/workspace.py b/api/workspace.py index 70226dd0..97c768f6 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -20,26 +20,11 @@ logger = logging.getLogger(__name__) from api.config import ( WORKSPACES_FILE as _GLOBAL_WS_FILE, LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE, + DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE, MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS ) -def _current_default_workspace() -> Path: - """Return the live default workspace from api.config. - - ``api.config.DEFAULT_WORKSPACE`` is mutable at runtime (for example after - ``save_settings()``). Importing it once into this module bakes in a stale - snapshot that can diverge from the actual current default and leak deleted - test workspaces back into live sessions. - """ - try: - from api import config as _config - - return Path(_config.DEFAULT_WORKSPACE).expanduser().resolve() - except Exception: - return Path.home().expanduser().resolve() - - # ── Profile-aware path resolution ─────────────────────────────────────────── def _profile_state_dir() -> Path: @@ -101,7 +86,12 @@ def _profile_default_workspace() -> str: return str(p) except (ImportError, Exception): logger.debug("Failed to load profile default workspace config") - return str(_current_default_workspace()) + try: + from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE + + return str(Path(_LIVE_DEFAULT_WORKSPACE).expanduser().resolve()) + except Exception: + return str(Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()) # ── Public API ────────────────────────────────────────────────────────────── @@ -442,7 +432,7 @@ def _trusted_workspace_roots() -> list[Path]: roots.append(p) add(Path.home()) - add(_current_default_workspace()) + add(_BOOT_DEFAULT_WORKSPACE) for w in load_workspaces(): add(w.get("path")) roots.sort(key=lambda p: len(str(p))) @@ -551,10 +541,11 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: /boot, /proc, /sys, /dev, /root on Linux/macOS; Windows system dirs). This prevents even admin-saved workspaces from pointing at OS internals. - None/empty path falls back to the current DEFAULT_WORKSPACE. + None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always + trusted (it was validated at server startup). """ if path in (None, ""): - return _current_default_workspace() + return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve() candidate = Path(path).expanduser().resolve() @@ -585,14 +576,14 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: except Exception: pass - # (C) Trusted if it is equal to or under the current DEFAULT_WORKSPACE. + # (C) Trusted if it is equal to or under the boot-time DEFAULT_WORKSPACE. # In Docker deployments HERMES_WEBUI_DEFAULT_WORKSPACE is often set to a # volume mount outside the user's home (e.g. /data/workspace). That path # was already validated at server startup, so any sub-path of it is safe # without requiring the user to add it to the workspace list manually. try: - current_default = _current_default_workspace() - candidate.relative_to(current_default) + boot_default = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve() + candidate.relative_to(boot_default) return candidate except ValueError: pass diff --git a/tests/test_workspace_stale_recovery.py b/tests/test_workspace_stale_recovery.py index 4206c434..31141525 100644 --- a/tests/test_workspace_stale_recovery.py +++ b/tests/test_workspace_stale_recovery.py @@ -15,7 +15,6 @@ def test_profile_default_workspace_uses_live_config_default(monkeypatch, tmp_pat monkeypatch.setattr(api_config, "get_config", lambda: {}) assert workspace._profile_default_workspace() == str(live_default.resolve()) - assert workspace.resolve_trusted_workspace(None) == live_default.resolve() def test_resolve_chat_workspace_with_recovery_repairs_missing_implicit_workspace(monkeypatch, tmp_path): From b3f8bee96f8a0d25652649cb65d4d5ee053e655f Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 12 May 2026 20:41:42 +0800 Subject: [PATCH 22/28] Fix settings system mobile version wrapping --- CHANGELOG.md | 2 ++ static/style.css | 3 +++ tests/test_mobile_layout.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6115c51f..7e8eb6bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **PR #2141** by @franksong2702 — Settings → System version badges and the `Check now` control now wrap on narrow mobile widths instead of forcing the section header past the viewport edge. Adds a mobile CSS regression guard for the section-header stacking and version-control wrapping behavior. Closes #2102. + - **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh. ## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback) diff --git a/static/style.css b/static/style.css index 7e87920f..e00028ca 100644 --- a/static/style.css +++ b/static/style.css @@ -2471,7 +2471,10 @@ main.main.showing-logs > #mainLogs{display:flex;} /* Responsive: tighten canvas on small screens. */ @media (max-width: 768px){ .settings-main{padding:16px 12px;} + .settings-section-head{flex-direction:column;align-items:flex-start;gap:8px;} .settings-section-title{font-size:16px;} + #checkUpdatesBlock{flex-wrap:wrap;row-gap:6px;width:100%;} + .settings-version-badge{white-space:nowrap;} .hermes-action-grid{grid-template-columns:1fr;} #mainSettings .settings-field{padding:14px;} } diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index 10846852..80f97e86 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -148,6 +148,29 @@ def test_mobile_breakpoint_640px_present(): "Missing @media(max-width:640px) breakpoint in style.css" +def test_settings_system_version_controls_wrap_on_phone_widths(): + """Settings -> System version badges must wrap instead of overflowing phones.""" + mobile_css = "\n".join(_max_width_media_blocks(768)) + assert ".settings-section-head" in mobile_css, ( + "Settings section header needs a mobile rule so title and update controls stack." + ) + assert "flex-direction:column" in mobile_css.replace(" ", ""), ( + "Settings section header should stack vertically on mobile." + ) + assert "#checkUpdatesBlock" in mobile_css, ( + "Settings update/version controls need a mobile rule." + ) + assert "flex-wrap:wrap" in mobile_css.replace(" ", ""), ( + "Version badges and Check now button must wrap instead of overflowing." + ) + assert "width:100%" in mobile_css.replace(" ", ""), ( + "The update controls row should take the available mobile width." + ) + assert ".settings-version-badge" in mobile_css and "white-space:nowrap" in mobile_css.replace(" ", ""), ( + "Individual version badges should stay intact while the group wraps." + ) + + def test_rightpanel_mobile_slide_over_css(): """Right panel must have position:fixed slide-over CSS for mobile.""" # At max-width:900px the rightpanel should be position:fixed, off-screen right From 76e611d49f79b82fe8a4f71baa7cffb9c7d3cb8b Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 12 May 2026 20:41:42 +0800 Subject: [PATCH 23/28] Preserve fallback provider credential hints --- CHANGELOG.md | 2 ++ api/streaming.py | 2 ++ tests/test_pr1339_fallback_providers_list.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6115c51f..42c77166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **PR #2140** by @franksong2702 — WebUI fallback provider activation now preserves `api_key` and `key_env` from `fallback_model` / `fallback_providers` entries before handing them to Hermes Agent, so env-backed fallback credentials can be resolved after a primary provider 401 instead of failing as an unauthenticated fallback. Closes #2133. + - **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh. ## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback) diff --git a/api/streaming.py b/api/streaming.py index fd772414..7a90d51e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2770,6 +2770,8 @@ def _run_agent_streaming( 'model': _fb_entry.get('model', ''), 'provider': _fb_entry.get('provider', ''), 'base_url': _fb_entry.get('base_url'), + 'api_key': _fb_entry.get('api_key'), + 'key_env': _fb_entry.get('key_env'), } # Build kwargs defensively — guard newer params so the WebUI diff --git a/tests/test_pr1339_fallback_providers_list.py b/tests/test_pr1339_fallback_providers_list.py index c180c8dd..c0a804f5 100644 --- a/tests/test_pr1339_fallback_providers_list.py +++ b/tests/test_pr1339_fallback_providers_list.py @@ -70,3 +70,21 @@ def test_fallback_resolved_initialized_to_none(): assert "_fallback_resolved = None" in block, ( "_fallback_resolved must be initialized to None so callers can rely on its presence" ) + + +def test_fallback_resolved_preserves_credential_hints(): + """Fallback entries must keep credential hints for AIAgent fallback activation.""" + block = _extract_fallback_block() + resolved_start = block.find("_fallback_resolved = {") + assert resolved_start != -1, "_fallback_resolved dict not found" + resolved_end = block.find("}", resolved_start) + resolved_dict = block[resolved_start:resolved_end] + + assert "'api_key': _fb_entry.get('api_key')" in resolved_dict, ( + "WebUI must preserve fallback_model/fallback_providers api_key so " + "AIAgent._try_activate_fallback can authenticate the fallback." + ) + assert "'key_env': _fb_entry.get('key_env')" in resolved_dict, ( + "WebUI must preserve fallback_model/fallback_providers key_env so " + "AIAgent._try_activate_fallback can resolve env-backed fallback keys." + ) From c5bad3e1c546afc3fb1874442c63ff31c7a32772 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 12 May 2026 14:56:30 +0200 Subject: [PATCH 24/28] i18n: add French (fr) locale Translation of all 938 string keys from English to French. Generated programmatically with Google Translate. --- static/i18n.js | 941 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 941 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index 84643cab..29996411 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -10639,6 +10639,947 @@ const LOCALES = { voice_mode_toggle_active: 'Exit voice mode', // TODO: translate subagent_children: 'Subagent sessions', // TODO: translate }, + + fr: { + offline_title: 'Connexion perdue', + offline_browser_detail: 'Votre navigateur signale que cet appareil est hors ligne.', + offline_network_detail: 'Hermes est actuellement inaccessible depuis ce navigateur.', + offline_autorefresh: 'J\'actualiserai automatiquement cette page lorsqu\'Hermès sera à nouveau joignable.', + offline_check_now: 'Vérifiez maintenant', + offline_checking: 'Vérification…', + offline_stream_waiting: 'Connexion perdue. En attendant de rafraîchir…', + _lang: 'fr', + _label: 'Français', + _speech: 'fr-FR', + cancelling: 'Annulation\u2026', + cancel_failed: 'Échec de l\'annulation :', + mic_denied: 'Accès au microphone refusé. Vérifiez les autorisations du navigateur.', + mic_no_speech: 'Aucune parole détectée. Essayer à nouveau.', + mic_network: 'Reconnaissance vocale indisponible.', + mic_error: 'Erreur de saisie vocale :', + voice_dictate: 'Dicter', + voice_dictate_active: 'Arrêter la dictée', + voice_mode_toggle: 'Mode vocal', + voice_mode_toggle_active: 'Quitter le mode vocal', + voice_listening: 'Écoute…', + voice_speaking: 'Parlant…', + voice_thinking: 'Pensée…', + voice_error: 'Voix non prise en charge dans ce navigateur', + voice_mode_active: 'Mode vocal activé', + voice_mode_off: 'Mode vocal désactivé', + session_imported: 'Session importée', + import_failed: 'Échec de l\'importation :', + import_invalid_json: 'JSON invalide', + image_pasted: 'Image collée :', + edit_message: 'Modifier le message', + regenerate: 'Régénérer la réponse', + copy: 'Copie', + copied: 'Copié!', + copy_failed: 'Échec de la copie', + diff_loading: 'Chargement des différences', + diff_error: 'Impossible de charger le fichier de correctif', + diff_too_large: 'Fichier de correctif trop volumineux pour être affiché en ligne', + tree_view: 'Arbre', + raw_view: 'Brut', + parse_failed_note: 'l\'analyse a échoué', + you: 'Toi', + mcp_servers_title: 'Serveurs MCP', + mcp_servers_desc: 'Affichez les serveurs MCP configurés dans config.yaml.', + mcp_no_servers: 'Aucun serveur MCP configuré.', + mcp_add_server: '+ Ajouter un serveur', + mcp_field_name: 'Nom du serveur', + mcp_transport_label: 'Type de transport', + mcp_field_command: 'Commande', + mcp_field_args: 'Arguments (séparés par des virgules)', + mcp_field_url: 'URL', + mcp_field_timeout: 'Délai d\'expiration (secondes)', + mcp_save: 'Sauvegarder', + mcp_cancel: 'Annuler', + mcp_name_required: 'Le nom du serveur est requis.', + mcp_url_required: 'L\'URL est requise pour le transport HTTP.', + mcp_command_required: 'La commande est requise pour le transport stdio.', + mcp_saved: 'Serveur MCP enregistré.', + mcp_save_failed: 'Échec de l\'enregistrement du serveur MCP.', + mcp_delete_confirm_title: 'Supprimer le serveur MCP', + mcp_delete_confirm_message: 'Supprimer le serveur MCP "{0}" ? Cette action ne peut pas être annulée.', + mcp_deleted: 'Serveur MCP supprimé.', + mcp_delete_failed: 'Échec de la suppression du serveur MCP.', + mcp_load_failed: 'Échec du chargement des serveurs MCP.', + mcp_restart_hint: 'Les modifications apportées au serveur sont en lecture seule ici pour le moment. Modifiez config.yaml et redémarrez Hermes pour que les modifications prennent effet.', + mcp_toggle_followup: 'Les contrôles d\'activation/désactivation sont intentionnellement différés jusqu\'à ce que la sémantique de rechargement MCP soit explicite.', + mcp_status_active: 'Actif', + mcp_status_configured: 'Configuré', + mcp_status_disabled: 'Désactivé', + mcp_status_invalid_config: 'Configuration invalide', + mcp_status_unknown: 'Inconnu', + mcp_tool_count: '{0} outils', + mcp_enabled_yes: 'Activé', + mcp_enabled_no: 'Désactivé', + mcp_tools_title: 'Outils MCP', + mcp_tools_desc: 'Recherchez des outils connus sur les serveurs MCP actifs.', + mcp_tools_search_placeholder: 'Outils de recherche par nom, serveur ou description…', + mcp_tools_no_tools: 'Aucun outil MCP n\'est disponible dans l\'inventaire d\'exécution actif.', + mcp_tools_no_matches: 'Aucun outil MCP ne correspond à votre recherche.', + mcp_tools_load_failed: 'Échec du chargement des outils MCP.', + mcp_tools_schema_empty: 'Aucun paramètre de schéma.', + mcp_tools_runtime_note: 'L\'inventaire des outils utilise uniquement les données d\'exécution MCP actives déjà connues ; le WebUI ne démarre pas et ne sonde pas les serveurs.', + pdf_loading: 'Chargement du PDF {0}…', + pdf_too_large: 'PDF trop volumineux pour un aperçu en ligne', + pdf_no_pages: 'Le PDF n\'a pas de pages', + pdf_error: 'Échec de l\'affichage de l\'aperçu PDF', + pdf_download: 'Télécharger le PDF', + html_loading: 'Chargement de l\'aperçu HTML…', + html_too_large: 'HTML trop volumineux pour l\'aperçu en ligne', + html_error: 'Échec de l\'affichage de l\'aperçu HTML', + html_open_full: 'Ouvrir la page entière', + html_sandbox_label: 'Aperçu HTML (en bac à sable)', + thinking: 'Pensée', + expand_all: 'Tout développer', + collapse_all: 'Tout réduire', + edit_failed: 'Échec de la modification :', + regen_failed: 'Échec de la régénération :', + reconnect_active: 'Une réponse est toujours en cours de génération. Recharger quand vous êtes prêt ?', + reconnect_finished: 'Une réponse était en cours lors de votre dernier départ. Les messages ont peut-être été mis à jour.', + approval_heading: 'Approbation requise', + approval_desc_prefix: 'Commande dangereuse détectée', + approval_btn_once: 'Autoriser une fois', + approval_btn_once_title: 'Autoriser cette commande (Entrée)', + approval_btn_session: 'Autoriser la session', + approval_btn_session_title: 'Autoriser cette session de conversation', + approval_btn_always: 'Toujours autoriser', + approval_btn_always_title: 'Toujours autoriser ce modèle de commande', + approval_btn_deny: 'Refuser', + approval_btn_deny_title: 'Refuser - n\'exécutez pas cette commande', + approval_responding: 'Répondre\u2026', + clarify_heading: 'Des éclaircissements sont nécessaires', + clarify_hint: 'Choisissez un choix ou saisissez votre propre réponse ci-dessous.', + clarify_other: 'Autre', + clarify_send: 'Envoyer', + clarify_input_placeholder: 'Tapez votre réponse…', + clarify_responding: 'Répondre\u2026', + untitled: 'Sans titre', + load_older_messages: '↑ Faites défiler vers le haut ou cliquez pour charger les anciens messages', + session_jump_start: 'Commencer', + session_jump_start_label: 'Aller au début de la session', + session_jump_end: 'Fin', + session_jump_end_label: 'Aller à la fin de la session', + queued_label: 'Envoie après réponse', + queued_cancel: 'Annuler le message en file d\'attente', + model_unavailable: '(indisponible)', + model_unavailable_title: 'Ce modèle ne figure plus dans votre liste de fournisseurs actuelle', + provider_mismatch_label: 'Inadéquation des fournisseurs', + model_not_found_label: 'Modèle introuvable', + model_custom_label: 'ID de modèle personnalisé', + model_custom_placeholder: 'par ex. openai/gpt-5.4', + model_search_placeholder: 'Rechercher des modèles…', + model_search_no_results: 'Aucun modèle trouvé', + model_group_configured: 'Configuré', + ws_search_placeholder: 'Rechercher des espaces de travail…', + ws_no_results: 'Aucun espace de travail trouvé', + workspace_new_worktree_conversation: 'Nouvelle conversation dans l\'arbre de travail', + workspace_new_worktree_conversation_meta: 'Créez un arbre de travail git isolé pour cet espace de travail.', + workspace_worktree_created: 'Conversation Worktree créée', + workspace_worktree_failed: 'La création de l\'arbre de travail a échoué :', + session_worktree_badge: 'Arbre de travail', + model_scope_advisory: 'S\'applique à cette conversation à partir de votre prochain message.', + model_scope_toast: 'S\'applique à cette conversation à partir de votre prochain message.', + cmd_clear: 'Messages de conversation clairs', + cmd_compress: 'Compresser manuellement le contexte de conversation (utilisation : /compress [thème principal])', + ctx_compress_hint: 'Compresser le contexte pour libérer de l\'espace →', + ctx_compress_action: '⚠ Compressez maintenant pour libérer le contexte', + cmd_compact_alias: 'Alias ​​hérité pour /compress', + cmd_model: 'Changer de modèle (par exemple /model gpt-4o)', + cmd_workspace: 'Changer d\'espace de travail par nom', + cmd_terminal: 'Ouvrez le terminal de l\'espace de travail', + cmd_new: 'Démarrer une nouvelle session de discussion', + cmd_usage: 'Activer/désactiver l\'affichage de l\'utilisation du jeton', + cmd_theme: 'Changer d\'apparence (thème : système/dark/light, skin : default/ares/mono/slate/poseidon/sisyphus/charizard)', + cmd_personality: 'Personnalité de l\'agent de commutation', + cmd_skills: 'Lister les compétences Hermès disponibles', + available_commands: 'Commandes disponibles :', + type_slash: 'Tapez / pour voir les commandes', + conversation_cleared: 'Conversation effacée', + command_label: 'Commande', + context_compaction_label: 'Compactage du contexte', + preserved_task_list_label: 'Liste de tâches préservée', + reference_only_label: 'Référence seulement', + model_usage: 'Utilisation : /model ', + no_model_match: 'Aucun modèle correspondant "', + switched_to: 'Passé à', + workspace_usage: 'Utilisation : /espace de travail ', + no_workspace_match: 'Aucun espace de travail correspondant "', + switched_workspace: 'Passé à l\'espace de travail :', + workspace_switch_failed: 'Échec du changement d\'espace de travail :', + new_session: 'Nouvelle session créée', + compressing: 'Demande de compression de contexte...', + compress_running_label: 'Compression', + compress_complete_label: 'Compression terminée', + auto_compress_label: 'Compression automatique', + compress_failed_label: 'Échec de la compression', + focus_label: 'Se concentrer', + token_usage_on: 'Utilisation du jeton sur', + token_usage_off: 'Utilisation des jetons désactivée', + theme_usage: 'Utilisation : /thème', + theme_set: 'Thème:', + no_active_session: 'Aucune session active', + cmd_queue: 'Mettre un message en file d\'attente pour le prochain tour', + cmd_goal: 'Définir ou inspecter un objectif persistant', + goal_evaluating_progress: 'Évaluer la progression des objectifs…', + goal_working_toward: 'Travailler vers l’objectif…', + goal_continuing_toast: 'Continuer vers l’objectif…', + goal_status_none: 'Aucun objectif actif. Définissez-en un avec /goal .', + goal_cleared: 'But dégagé.', + goal_no_goal: 'Aucun objectif actif.', + cmd_interrupt: 'Annuler le tour en cours et envoyer un nouveau message', + cmd_steer: 'Injecter une correction à mi-tour sans interrompre l\'agent', + cmd_queue_no_msg: 'Utilisation : /queue ', + cmd_queue_not_busy: 'Aucune tâche active - envoyez simplement normalement', + cmd_queue_confirm: 'Message en file d\'attente', + cmd_interrupt_no_msg: 'Utilisation : /interruption ', + cmd_interrupt_confirm: 'Interrompu — envoi d\'un nouveau message', + cmd_steer_no_msg: 'Utilisation : /steer ', + cmd_steer_fallback: 'Steer indisponible – mis en file d’attente pour le prochain tour à la place', + cmd_steer_delivered: 'Steer livré - l\'agent le verra sur son prochain résultat d\'outil', + steer_leftover_queued: 'Bœuf en attente pour le prochain tour', + busy_steer_fallback: 'Steer indisponible – en file d’attente pour le prochain tour', + busy_interrupt_confirm: 'Interrompu — envoi d\'un nouveau message', + settings_label_busy_input_mode: 'Mode de saisie occupé', + settings_desc_busy_input_mode: 'Contrôle ce qui se passe lorsque vous envoyez un message pendant l\'exécution de l\'agent. La file d\'attente attend ; L\'interruption s\'annule et recommence ; Steer injecte une correction à mi-tour sans interrompre (revient en file d\'attente lorsque l\'agent ou le flux est indisponible).', + settings_busy_input_mode_queue: 'Suivi de file d\'attente', + settings_busy_input_mode_interrupt: 'Interrompre le tour en cours', + settings_busy_input_mode_steer: 'Direction (correction à mi-virage)', + status_profile: 'Profil', + status_hermes_home: 'Maison Hermès', + status_started: 'Commencé', + status_updated: 'Mis à jour', + status_tokens: 'Jetons', + status_ephemeral: 'Instantané éphémère – non enregistré dans l’historique des transcriptions.', + status_no_tokens: 'Aucun jeton utilisé', + status_unknown: 'Inconnu', + usage_personality_none: 'aucun', + session_toolsets: 'Ensembles d\'outils de session', + session_toolsets_desc: 'Restreindre les outils disponibles pour cette session (vide = utiliser la configuration globale)', + session_toolsets_global: 'Global (par défaut)', + session_toolsets_custom: 'Coutume', + session_toolsets_placeholder: 'outil1, outil2, …', + session_toolsets_apply: 'Appliquer', + session_toolsets_clear: 'Effacer (utiliser global)', + session_toolsets_applied: 'Ensembles d\'outils mis à jour', + session_toolsets_cleared: 'Ensembles d\'outils effacés - à l\'aide de la configuration globale', + session_toolsets_failed: 'Échec de la mise à jour des ensembles d\'outils :', + no_personalities: 'Aucune personnalité trouvée (ajoutez-les à ~/.hermes/personalities/)', + available_personalities: 'Personnalités disponibles :', + personality_switch_hint: '\\n\\nUtilisez `/personality ` pour changer, ou `/personality none` pour effacer.', + personalities_load_failed: 'Échec du chargement des personnalités', + personality_cleared: 'Personnalité effacée', + personality_set: 'Personnalité:', + failed_colon: 'Échoué:', + no_workspace: 'Pas d\'espace de travail', + terminal_open_title: 'Terminal d\'espace de travail ouvert', + terminal_no_workspace_title: 'Sélectionnez un espace de travail pour ouvrir le terminal', + terminal_title: 'Terminal', + terminal_clear: 'Clair', + terminal_copy_output: 'Copier la sortie', + terminal_restart: 'Redémarrage', + terminal_collapse: 'Effondrement', + terminal_expand: 'Développer', + terminal_close: 'Fermer', + terminal_input_placeholder: 'Exécutez une commande...', + terminal_start_failed: 'Échec du démarrage du terminal :', + terminal_input_failed: 'L\'entrée du terminal a échoué :', + terminal_copy_failed: 'Échec de la copie :', + terminal_error: 'Erreur de terminal', + workspace_empty_no_path: 'Aucun espace de travail sélectionné. Définissez un espace de travail dans Paramètres \u2192 Espace de travail pour parcourir les fichiers.', + workspace_empty_dir: 'Cet espace de travail est vide.', + workspace_show_hidden_files: 'Afficher les fichiers cachés', + workspace_show_hidden_files_desc: 'Incluez .DS_Store, .git, node_modules et d\'autres fichiers cachés/système dans l\'arborescence des fichiers.', + workspace_hidden_files_visible: 'caché visible', + workspace_hidden_files_visible_title: 'Les fichiers cachés sont visibles – cliquez pour les options', + workspace_options: 'Options de l\'espace de travail', + dialog_confirm_title: 'Confirmer l\'action', + dialog_prompt_title: 'Entrez une valeur', + dialog_confirm_btn: 'Confirmer', + unsaved_confirm: 'Vous avez des modifications non enregistrées dans l\'aperçu. Supprimer et naviguer ?', + discard: 'Jeter', + save: 'Sauvegarder', + edit: 'Modifier', + clear: 'Clair', + create: 'Créer', + remove: 'Retirer', + save_title: 'Enregistrer les modifications', + edit_title: 'Modifier ce fichier', + saved: 'Enregistré', + save_failed: 'Échec de l\'enregistrement :', + image_load_failed: 'Impossible de charger l\'image', + file_open_failed: 'Impossible d\'ouvrir le fichier', + double_click_rename: 'Double-cliquez pour renommer', + renamed_to: 'Renommé en', + rename_failed: 'Échec du changement de nom :', + delete_title: 'Supprimer', + rename_title: 'Rebaptiser', + rename_prompt: 'Nouveau nom :', + deleted: 'Supprimé', + delete_failed: 'Échec de la suppression :', + reveal_in_finder: 'Révéler dans le gestionnaire de fichiers', + reveal_failed: 'Échec de la révélation :', + copy_file_path: 'Copier le chemin du fichier', + path_copied: 'Chemin du fichier copié dans le presse-papiers', + path_copy_failed: 'Échec de la copie du chemin :', + session_rename: 'Renommer la conversation', + session_rename_desc: 'Modifier le titre de cette conversation', + new_file_prompt: 'Nouveau nom de fichier (par exemple notes.md) :', + project_name_prompt: 'Nom du projet :', + created: 'Créé', + create_failed: 'Échec de la création :', + new_folder_prompt: 'Nouveau nom de dossier :', + folder_created: 'Dossier créé', + folder_create_failed: 'Échec de la création du dossier :', + remove_title: 'Retirer', + empty_dir: '(vide)', + upload_failed: 'Échec du téléchargement :', + session_pin: 'Épingler la conversation', + session_unpin: 'Désépingler la conversation', + session_pin_desc: 'Gardez cette conversation en haut', + session_unpin_desc: 'Supprimer de l\'épinglé', + session_pin_failed: 'Échec de la broche :', + session_move_project: 'Passer au projet', + session_move_project_desc_has: 'Changer le projet pour cette conversation', + session_move_project_desc_none: 'Attribuer un projet à cette conversation', + session_archive: 'Archiver une conversation', + session_restore: 'Restaurer la conversation', + session_archive_desc: 'Masquer cette conversation jusqu\'à ce que l\'archive soit affichée', + session_archive_worktree_desc: 'Cachez cette conversation ; garder son arbre de travail sur le disque', + session_restore_desc: 'Ramenez cette conversation dans la liste principale', + session_archived: 'Séance archivée', + session_archived_worktree: 'Séance archivée. Worktree reste sur le disque.', + session_restored: 'Session restaurée', + session_archive_failed: 'Échec de l\'archivage :', + session_duplicate: 'Conversation en double', + session_duplicate_desc: 'Créer une copie avec le même espace de travail et le même modèle', + session_duplicated: 'Session dupliquée', + session_duplicate_failed: 'Échec de la duplication :', + session_stop_response: 'Arrêter la réponse', + session_stop_response_desc: 'Annuler la réponse en cours pour cette conversation', + session_delete: 'Supprimer la conversation', + session_delete_desc: 'Supprimer définitivement cette conversation', + session_delete_confirm: 'Supprimer cette conversation ?', + 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_select_mode: 'Sélectionner', + session_select_mode_desc: 'Sélectionnez les conversations à gérer par lots', + session_select_all: 'Tout sélectionner', + session_deselect_all: 'Tout désélectionner', + session_selected_count: '{0} sélectionné', + session_batch_archive: 'Archive', + session_batch_delete: 'Supprimer', + session_batch_move: 'Passer au projet', + session_batch_delete_confirm: 'Supprimer {0} conversations ?', + session_batch_archive_confirm: 'Archiver {0} conversations ?', + session_batch_delete_worktree_confirm: 'Supprimer {0} conversations ? {1} conversations basées sur Worktree laisseront leurs répertoires Worktree sur le disque.', + session_batch_archive_worktree_confirm: 'Archiver {0} conversations ? {1} conversations basées sur un arbre de travail conserveront leurs répertoires d\'arbre de travail sur le disque.', + session_no_selection: 'Aucune conversation sélectionnée', + settings_heading_title: 'Centre de contrôle', + settings_heading_subtitle: 'Préférences, outils de conversation et contrôles système.', + settings_section_conversation_title: 'Conversation', + settings_section_appearance_title: 'Apparence', + settings_section_appearance_meta: 'Thème, couleurs d\'accent et style visuel.', + settings_section_preferences_title: 'Préférences', + settings_section_preferences_meta: 'Paramètres par défaut et comportement de l\'interface utilisateur pour Hermes Web UI.', + settings_section_system_title: 'Système', + settings_section_system_meta: 'Version de l\'instance et contrôles d\'accès.', + settings_check_now: 'Vérifiez maintenant', + settings_checking: 'Vérification\u2026', + settings_up_to_date: 'À jour \u2713', + settings_updates_available: '{count} mises à jour disponibles', + settings_updates_disabled: 'Vérifications de mise à jour désactivées', + settings_update_check_failed: 'La vérification de la mise à jour a échoué', + settings_label_workspace_panel_open: 'Garder le panneau de l\'espace de travail ouvert par défaut', + settings_desc_workspace_panel_open: 'Lorsqu\'il est activé, le panneau de l\'espace de travail/navigateur de fichiers s\'ouvre automatiquement à chaque nouvelle session. Vous pouvez toujours le fermer manuellement à tout moment.', + settings_label_session_jump_buttons: 'Afficher les boutons de saut de session', + settings_desc_session_jump_buttons: 'Affichez les boutons flottants de début et de fin lors de la lecture de longs historiques de session.', + settings_label_session_endless_scroll: 'Charger les anciens messages en faisant défiler vers le haut', + settings_desc_session_endless_scroll: 'Lorsqu\'ils sont activés, les anciens messages se chargent automatiquement lorsque vous faites défiler vers le haut. Lorsqu\'il est désactivé, utilisez le bouton des messages plus anciens.', + open_in_browser: 'Ouvrir dans le navigateur', + settings_dropdown_conversation: 'Conversation', + settings_dropdown_appearance: 'Apparence', + settings_dropdown_preferences: 'Préférences', + settings_dropdown_providers: 'Fournisseurs', + settings_dropdown_system: 'Système', + settings_tab_conversation: 'Conversation', + settings_tab_appearance: 'Apparence', + settings_tab_preferences: 'Préférences', + settings_tab_system: 'Système', + settings_title: 'Paramètres', + settings_save_btn: 'Enregistrer les paramètres', + settings_label_model: 'Modèle par défaut', + settings_desc_model: 'Utilisé pour les nouvelles conversations. Les conversations existantes conservent leur modèle sélectionné.', + settings_label_send_key: 'Envoyer la clé', + settings_label_theme: 'Thème', + settings_label_skin: 'Peau', + settings_label_font_size: 'Taille de la police', + font_size_small: 'Petit', + font_size_default: 'Défaut', + font_size_large: 'Grand', + settings_autosave_saving: 'Économie…', + settings_autosave_saved: 'Enregistré', + settings_autosave_failed: 'Échec de l\'enregistrement', + settings_autosave_retry: 'Réessayer', + settings_label_language: 'Langue', + settings_label_token_usage: 'Afficher l\'utilisation du jeton', + settings_label_sidebar_density: 'Densité de la barre latérale', + cmd_reasoning: 'Basculez la visibilité de la réflexion (afficher/masquer), définir le niveau d\'effort ou vérifier l\'état actuel', + settings_label_external_sessions: 'Afficher les sessions non-WebUI', + settings_label_sync_insights: 'Synchroniser avec les insights', + settings_label_check_updates: 'Vérifier les mises à jour', + settings_label_bot_name: 'Nom de l\'assistant', + settings_label_password: 'Mot de passe d\'accès', + settings_saved: 'Paramètres enregistrés', + settings_save_failed: 'Échec de l\'enregistrement :', + settings_load_failed: 'Échec du chargement des paramètres :', + settings_saved_pw: 'Paramètres enregistrés : protection par mot de passe activée et ce navigateur reste connecté', + settings_saved_pw_updated: 'Paramètres enregistrés – mot de passe mis à jour', + login_title: 'Se connecter', + login_subtitle: 'Entrez votre mot de passe pour continuer', + login_placeholder: 'Mot de passe', + login_btn: 'Se connecter', + login_invalid_pw: 'Mot de passe invalide', + login_conn_failed: 'La connexion a échoué', + dialog_confirm_title: 'Confirmer l\'action', + dialog_prompt_title: 'Entrez une valeur', + dialog_confirm_btn: 'Confirmer', + discard: 'Jeter', + clear: 'Clair', + create: 'Créer', + remove: 'Retirer', + project_name_prompt: 'Nom du projet :', + tab_chat: 'Chat', + tab_tasks: 'Tâches', + tab_skills: 'Compétences', + tab_memory: 'Mémoire', + tab_workspaces: 'Espaces', + tab_profiles: 'Profils', + tab_kanban: 'Kanban', + kanban_board: 'Conseil', + kanban_visible_tasks: '{0} tâches visibles', + kanban_search_tasks: 'Tâches de recherche', + kanban_all_assignees: 'Tous les assignés', + kanban_all_tenants: 'Tous les locataires', + kanban_include_archived: 'Inclure archivé', + kanban_no_matching_tasks: 'Aucune tâche correspondante', + kanban_no_data: 'Aucune donnée Kanban', + kanban_work_queue_hint: 'Il s\'agit de la file d\'attente de travail de l\'agent Hermes. Créez ou triez une tâche, attribuez-la, déplacez-la vers Prêt, puis laissez le répartiteur la réclamer.', + kanban_unavailable: 'Kanban indisponible', + kanban_read_only: 'Vue en lecture seule', + kanban_empty: 'Vide', + kanban_task: 'Tâche', + kanban_no_description: 'Pas de description', + kanban_refresh: 'Rafraîchir', + kanban_status_triage: 'Triage', + kanban_status_todo: 'Faire', + kanban_status_ready: 'Prêt', + kanban_status_running: 'En cours d\'exécution', + kanban_status_blocked: 'Bloqué', + kanban_status_done: 'Fait', + kanban_comments_count: 'Commentaires ({0})', + kanban_events_count: 'Événements ({0})', + kanban_links: 'Links', + kanban_parents: 'Parents', + kanban_children: 'Enfants', + kanban_runs_count: 'Exécutions ({0})', + kanban_no_comments: 'Sans commentaires', + kanban_no_events: 'Aucun événement', + kanban_no_runs: 'Aucune course', + kanban_title: 'Titre', + kanban_description: 'Description', + kanban_description_placeholder: 'Facultatif : ce qui doit se passer, les critères d\'acceptation, les liens', + kanban_status: 'Statut', + kanban_assignee: 'Cessionnaire', + kanban_assignee_placeholder: 'Facultatif – laisser vide pour tout travailleur', + kanban_tenant: 'Locataire', + kanban_tenant_placeholder: 'Facultatif – slug de projet ou d\'équipe', + kanban_priority: 'Priorité', + kanban_priority_hint: 'Les nombres plus élevés sont exécutés en premier. Par défaut 0.', + kanban_title_required: 'Le titre est requis.', + kanban_new_task: 'Nouvelle tâche', + kanban_edit_task: 'Modifier la tâche', + kanban_status_original_hint: 'Statut actuel : {0}. Cette boîte de dialogue prend uniquement en charge les modifications Triage/Todo/Ready.', + kanban_run_dispatcher: 'Exécuter le répartiteur', + kanban_run_dispatcher_confirm: 'Cela réclamera des tâches prêtes sur ce tableau et générera des sous-processus de travail (un par tâche, jusqu\'à 8 par clic). Continuer?', + kanban_assignee_profiles_label: 'Profils Hermès', + kanban_assignee_other_label: 'Autre (voies CLI / profils supprimés)', + kanban_assignee_unassigned: '— Non attribué (ne s\'exécutera pas automatiquement) —', + kanban_ready_needs_assignee: 'Vous avez sélectionné Non attribué + Prêt. Le répartiteur ignorera cette tâche. Soumettez à nouveau pour confirmer ou choisissez un profil.', + kanban_dispatch_preview_prefix: 'Aperçu :', + kanban_dispatch_run_prefix: 'Expédié :', + kanban_dispatch_spawned: 'engendré', + kanban_dispatch_promoted: 'promu', + kanban_dispatch_reclaimed: 'récupéré', + kanban_dispatch_skipped_unassigned: 'ignoré (pas de destinataire)', + kanban_dispatch_skipped_nonspawnable: 'ignoré (profil inconnu)', + kanban_dispatch_auto_blocked: 'auto-bloqué', + kanban_dispatch_timed_out: 'expiré', + kanban_dispatch_crashed: 's\'est écrasé', + kanban_add_comment: 'Ajouter un commentaire', + kanban_status_archived: 'Archivé', + tab_todos: 'Toutes les tâches', + tab_insights: 'Connaissances', + tab_dashboard: 'Tableau de bord Hermès', + dashboard_loopback_warning: 'Le tableau de bord est en boucle uniquement sur le serveur. Naviguez depuis le serveur lui-même ou redémarrez-le avec --host 0.0.0.0 (non sécurisé).', + tab_logs: 'Journaux', + tab_settings: 'Paramètres', + new_conversation: 'Nouvelle conversation', + filter_conversations: 'Filtrer les conversations...', + session_time_unknown: 'Inconnu', + session_time_last_week: '1w', + session_time_bucket_today: 'Aujourd\'hui', + session_time_bucket_yesterday: 'Hier', + session_time_bucket_this_week: 'Cette semaine', + session_time_bucket_last_week: 'La semaine dernière', + session_time_bucket_older: 'Plus vieux', + scheduled_jobs: 'Travaux planifiés', + new_job: 'Nouvel emploi', + loading: 'Chargement...', + search_skills: 'Compétences de recherche...', + new_skill: 'Nouvelle compétence', + personal_memory: 'Mémoire personnelle', + current_task_list: 'Liste de tâches actuelle', + logs_title: 'Journaux', + logs_file: 'Déposer', + logs_tail: 'Queue', + logs_auto_refresh: 'Actualisation automatique (5s)', + logs_wrap: 'Enrouler les lignes', + logs_copy_all: 'Copier tout', + logs_empty: 'Aucune ligne de journal pour l\'instant.', + logs_loading: 'Chargement des journaux…', + logs_load_failed: 'Les journaux n\'ont pas pu être chargés', + logs_status_idle: 'Choisissez un fichier journal pour afficher les lignes récentes.', + logs_no_mtime: 'pas encore écrit', + logs_truncated_hint: 'Afficher la fin d\'un gros fichier journal ; les octets plus anciens ont été ignorés pour limiter la mémoire.', + logs_copied: 'Journaux copiés', + logs_severity: 'Gravité', + logs_severity_all: 'Tous', + logs_severity_errors: 'Erreurs', + logs_severity_warnings: 'Avertissements+', + logs_filter_active: 'affiché (filtre actif)', + insights_title: 'Analyse d\'utilisation', + insights_sessions: 'Séances', + insights_messages: 'Messages', + insights_tokens: 'Jetons', + insights_cost: 'Coût estimé', + insights_no_cost: 'N / A', + insights_models: 'Modèles', + insights_activity_by_day: 'Activité par jour', + insights_activity_by_hour: 'Activité par heure', + insights_peak_hour: 'Pic : {heure}', + insights_token_breakdown: 'Répartition des jetons', + insights_input_tokens: 'Saisir', + insights_output_tokens: 'Sortir', + insights_total: 'Total', + insights_daily_tokens: 'Jetons quotidiens', + insights_model_name: 'Modèle', + insights_model_sessions: 'Séances', + insights_model_tokens: 'Jetons', + insights_model_cost: 'Coût', + insights_model_share: 'Partager', + insights_no_usage_data: 'Aucune donnée d\'utilisation pour l\'instant', + insights_footer: 'Affichage des données des {days} derniers jours', + workspace_desc: 'Ajoutez et changez d\'espace de travail pour vos sessions.', + session_lineage_segment_untitled: 'Segment sans titre', + session_lineage_segment_open: 'Segment de lignée ouverte', + new_profile: 'Nouveau profil', + transcript: 'Transcription', + download_transcript: 'Télécharger en Markdown', + import: 'Importer', + settings_label_sound: 'Son de notification', + settings_desc_sound: 'Jouez un son lorsque l\'assistant termine une réponse.', + tts_listen: 'Écouter', + tts_not_supported: 'La synthèse vocale n\'est pas prise en charge dans ce navigateur.', + settings_label_tts: 'Text-to-Speech pour les réponses', + settings_desc_tts: 'Affichez un bouton haut-parleur sur chaque message de l\'assistant pour le lire à haute voix à l\'aide de la synthèse vocale de votre navigateur.', + settings_label_tts_auto_read: 'Lecture automatique des réponses à haute voix', + settings_desc_tts_auto_read: 'Prononcez automatiquement chaque nouvelle réponse de l\'assistant lorsqu\'elle est terminée. S\'arrête lorsque vous commencez à taper.', + settings_label_voice_mode: 'Bouton du mode vocal mains libres', + settings_desc_voice_mode: 'Affichez le bouton du mode vocal (forme d\'onde audio) à côté du micro de dictée. Vous permet de parler naturellement : Hermes envoie automatiquement après une pause et lit les réponses à haute voix. Nécessite un navigateur prenant en charge à la fois la reconnaissance vocale et TTS.', + settings_label_tts_voice: 'Voix', + settings_desc_tts_voice: 'Voix préférée. Rempli à partir des voix disponibles dans votre navigateur.', + settings_label_tts_rate: 'Taux de parole', + settings_label_tts_pitch: 'Emplacement du discours', + settings_label_notifications: 'Notifications du navigateur', + settings_desc_notifications: 'Afficher une notification système lorsqu\'une réponse est terminée alors que l\'application est en arrière-plan.', + settings_desc_token_usage: 'Affiche le nombre de jetons d’entrée/sortie sous chaque réponse de l’assistant. Également basculé avec /usage.', + settings_label_api_redact: 'Expurger les données sensibles dans les réponses API', + settings_desc_api_redact: 'Les utilisateurs auto-hébergés peuvent désactiver pour des raisons de transparence (non recommandé pour les instances partagées).', + settings_sidebar_density_compact: 'Compact', + settings_sidebar_density_detailed: 'Détaillé', + settings_desc_sidebar_density: 'Contrôle la quantité de métadonnées que la liste des sessions affiche dans la barre latérale gauche.', + settings_label_auto_title_refresh: 'Actualisation adaptative du titre', + settings_auto_title_refresh_off: 'Désactivé', + settings_auto_title_refresh_5: 'Tous les 5 échanges', + settings_auto_title_refresh_10: 'Tous les 10 échanges', + settings_auto_title_refresh_20: 'Tous les 20 échanges', + settings_desc_auto_title_refresh: 'Régénère automatiquement le titre de la session en fonction du dernier échange, le gardant ainsi pertinent à mesure que la conversation évolue. Nécessite la configuration d’un modèle de génération de titres LLM.', + settings_desc_external_sessions: 'Affichez les conversations de CLI, Telegram, Discord, Slack et d\'autres chaînes dans la liste des sessions. Cliquez pour importer et continuer.', + settings_desc_sync_insights: 'Met en miroir l\'utilisation du jeton WebUI dans state.db afin que Hermes /insights inclut les données de session du navigateur. Désactivé par défaut.', + settings_desc_check_updates: 'Afficher une bannière lorsque des versions plus récentes de WebUI ou de l\'agent sont disponibles. Exécute périodiquement une récupération git en arrière-plan.', + settings_desc_bot_name: 'Nom d’affichage de l’assistant dans l’interface utilisateur. Par défaut, Hermès.', + settings_desc_password: 'Saisissez un nouveau mot de passe pour le définir ou le modifier. Laissez vide pour conserver le paramètre actuel.', + password_placeholder: 'Entrez le nouveau mot de passe…', + password_env_var_locked: 'La variable d\'environnement HERMES_WEBUI_PASSWORD est actuellement définie et est prioritaire. Désactivez-le et redémarrez le serveur pour gérer le mot de passe à partir d\'ici.', + password_env_var_locked_placeholder: 'Verrouillé : la variable d\'environnement HERMES_WEBUI_PASSWORD est définie', + disable_auth: 'Désactiver l\'authentification', + sign_out: 'Se déconnecter', + providers_tab_title: 'Fournisseurs', + providers_section_title: 'Fournisseurs', + providers_section_meta: 'Gérez les clés API pour les fournisseurs d\'IA. Les modifications prennent effet immédiatement.', + providers_status_configured: 'Clé API configurée', + providers_status_not_configured: 'Aucune clé API', + providers_status_oauth: 'OAuth', + providers_status_api_key: 'Clé API', + providers_status_not_configured_label: 'Non configuré', + providers_oauth_hint: 'Authentifié via OAuth. Aucune clé API nécessaire.', + providers_oauth_config_yaml_hint: 'Jeton configuré via config.yaml. Pour mettre à jour, modifiez la section des fournisseurs dans votre config.yaml ou exécutez Hermes Auth.', + providers_oauth_not_configured_hint: 'Non authentifié. Exécutez Hermes Auth dans le terminal pour configurer ce fournisseur.', + providers_save: 'Sauvegarder', + providers_remove: 'Retirer', + providers_saving: 'Économie…', + providers_removing: 'Suppression…', + providers_enter_key: 'Veuillez saisir une clé API', + providers_empty: 'Aucun fournisseur configurable trouvé.', + providers_key_updated: 'Clé API enregistrée', + providers_key_removed: 'Clé API supprimée', + providers_key_placeholder_new: 'sk-...', + providers_key_placeholder_replace: 'Entrez une nouvelle clé à remplacer…', + cancel: 'Annuler', + create_job: 'Créer un emploi', + save_skill: 'Enregistrer la compétence', + editing: 'Édition', + empty_title: 'En quoi puis-je aider ?', + empty_subtitle: 'Demandez n\'importe quoi, exécutez des commandes, explorez des fichiers ou gérez vos tâches planifiées.', + suggest_files: 'Quels fichiers se trouvent dans cet espace de travail ?', + suggest_schedule: 'Quel est mon programme aujourd\'hui ?', + suggest_plan: 'Aide-moi à planifier un petit projet.', + onboarding_badge: 'PREMIÈRE EXÉCUTION', + onboarding_title: 'Bienvenue dans l\'interface utilisateur Web Hermès', + onboarding_lead: 'Une configuration guidée rapide vérifiera Hermes, enregistrera une configuration réelle du fournisseur, choisira un espace de travail et un modèle et protégera éventuellement l\'application avec un mot de passe.', + onboarding_back: 'Dos', + onboarding_continue: 'Continuer', + onboarding_skip: 'Ignorer la configuration', + onboarding_skipped: 'Configuration ignorée – en utilisant la configuration existante.', + onboarding_open: 'Hermès ouvert', + onboarding_step_system_title: 'Vérification du système', + onboarding_step_system_desc: 'Vérifiez la visibilité de l\'agent Hermes et de la configuration.', + onboarding_step_setup_title: 'Configuration du fournisseur', + onboarding_step_setup_desc: 'Enregistrez la configuration minimale du fournisseur Hermes.', + onboarding_step_workspace_title: 'Espace de travail + modèle', + onboarding_step_workspace_desc: 'Choisissez les valeurs par défaut pour les nouvelles sessions et le chat.', + onboarding_step_password_title: 'Mot de passe facultatif', + onboarding_step_password_desc: 'Protégez l’interface utilisateur Web avant de la partager.', + onboarding_step_finish_title: 'Finition', + onboarding_step_finish_desc: 'Vérifiez et entrez dans l\'application.', + onboarding_notice_system_ready: 'L\'agent Hermes semble accessible depuis l\'interface utilisateur Web.', + onboarding_notice_system_unavailable: 'Hermes Agent n’est pas encore entièrement disponible. Bootstrap peut l\'installer, mais la configuration du fournisseur peut toujours nécessiter un terminal.', + onboarding_check_agent: 'Agent Hermès', + onboarding_check_agent_ready: 'Détecté et importable', + onboarding_check_agent_missing: 'Manquant ou partiellement importable', + onboarding_check_password: 'Mot de passe', + onboarding_check_password_enabled: 'Déjà activé', + onboarding_check_password_disabled: 'Pas encore activé', + onboarding_check_provider: 'Configuration du fournisseur', + onboarding_check_provider_ready: 'Prêt à discuter', + onboarding_check_provider_partial: 'Enregistré mais incomplet', + onboarding_check_provider_pending: 'Vérification nécessaire', + onboarding_config_file: 'Fichier de configuration :', + onboarding_env_file: 'Fichier .env :', + onboarding_unknown: 'Inconnu', + onboarding_current_provider: 'Configuration actuelle :', + onboarding_missing_imports: 'Importations manquantes :', + onboarding_notice_setup_required: 'Choisissez ici un chemin de fournisseur simple. Les flux OAuth avancés appartiennent toujours à la CLI Hermes pour le moment.', + onboarding_notice_setup_already_ready: 'Une configuration de fournisseur Hermes fonctionnelle est déjà détectée. Vous pouvez le conserver ou le remplacer ici.', + onboarding_oauth_provider_ready_title: 'Fournisseur déjà authentifié', + onboarding_oauth_provider_ready_body: 'Cette instance est configurée pour utiliser un fournisseur OAuth ({provider}) configuré via la CLI Hermes. Aucune clé API n\'est nécessaire ici - cliquez sur Continuer pour terminer la configuration.', + onboarding_oauth_provider_not_ready_title: 'Fournisseur OAuth pas encore authentifié', + onboarding_oauth_provider_not_ready_body: 'Cette instance est configurée pour utiliser {provider}, qui utilise OAuth plutôt qu\'une clé API. Exécutez hermes auth ou hermes model dans un terminal pour vous authentifier, puis rechargez l\'interface utilisateur Web.', + onboarding_oauth_switch_hint: 'Ou choisissez un autre fournisseur ci-dessous pour passer à une configuration par clé API :', + oauth_login_codex: 'Connectez-vous avec Codex (ChatGPT)', + oauth_codex_step1: 'Étape 1 : Visitez cette URL et entrez le code', + oauth_codex_step2: 'Étape 2 : Saisissez ce code sur la page', + oauth_codex_polling: 'En attente d\'autorisation...', + oauth_codex_success: 'Connexion au Codex OAuth réussie !', + oauth_codex_error: 'Échec de la connexion OAuth', + oauth_codex_expired: 'Code expiré, veuillez réessayer', + onboarding_notice_workspace: 'Ces valeurs réutilisent les mêmes API de paramètres que l\'application normale.', + onboarding_workspace_label: 'Espace de travail', + onboarding_workspace_or_path: 'Ou entrez un chemin d\'espace de travail', + onboarding_workspace_placeholder: '/accueil/vous/espace de travail', + onboarding_provider_label: 'Mode configuration', + onboarding_quick_setup_badge: 'configuration rapide', + provider_category_easy_start: 'Démarrage facile', + provider_category_self_hosted: 'Ouvert / auto-hébergé', + provider_category_specialized: 'Spécialisé', + onboarding_api_key_label: 'Clé API', + onboarding_api_key_placeholder: 'Laisser vide pour conserver une clé enregistrée existante', + onboarding_api_key_label_optional: 'Clé API (facultatif)', + onboarding_api_key_placeholder_optional: 'Laisser vide pour les serveurs sans clé', + onboarding_api_key_help_keyless: 'La plupart des installations LM Studio / Ollama / vLLM s\'exécutent sans clé — laissez ce champ vide si votre serveur ne nécessite pas d\'authentification. Utilisez le bouton Tester la connexion pour vérifier.', + onboarding_api_key_help_prefix: 'Enregistré comme secret dans votre fichier Hermes .env en utilisant', + onboarding_base_url_label: 'URL de base', + onboarding_base_url_placeholder: 'https://votre-endpoint.example/v1', + onboarding_base_url_help: 'Utilisez-le pour les routeurs compatibles OpenAI, les serveurs auto-hébergés, LiteLLM, Ollama, LM Studio, vLLM ou des points de terminaison similaires.', + onboarding_model_label: 'Modèle par défaut', + onboarding_workspace_help: 'Choisissez le modèle qu\'Hermes doit utiliser pour les nouvelles discussions une fois la configuration terminée.', + onboarding_custom_model_placeholder: 'votre-nom-de-modèle', + onboarding_custom_model_help: 'Pour les points de terminaison personnalisés, entrez l’ID de modèle exact attendu par votre serveur.', + onboarding_notice_password_enabled: 'Un mot de passe est déjà configuré. Saisissez-en un nouveau uniquement si vous souhaitez le remplacer.', + onboarding_notice_password_recommended: 'Facultatif mais recommandé si vous exposez l\'interface utilisateur au-delà de localhost.', + onboarding_password_label: 'Mot de passe (facultatif)', + onboarding_password_placeholder: 'Laisser vide pour sauter', + onboarding_password_help: 'Les mots de passe sont stockés via l\'API des paramètres existants et hachés côté serveur.', + onboarding_notice_finish: 'Vous pouvez rouvrir les paramètres plus tard pour modifier tout cela.', + onboarding_not_set: 'Non défini', + onboarding_password_will_enable: 'Sera activé', + onboarding_password_will_replace: 'Sera remplacé', + onboarding_password_keep_existing: 'Conserver le mot de passe actuel', + onboarding_password_remains_disabled: 'Restera invalide', + onboarding_password_skipped: 'Ignoré pour l\'instant', + onboarding_finish_help: 'La finition stocke onboarding_completed dans les paramètres et vous amène dans l\'application normale.', + onboarding_error_choose_workspace: 'Choisissez un espace de travail avant de continuer.', + onboarding_error_choose_model: 'Choisissez un modèle avant de continuer.', + onboarding_error_provider_required: 'Choisissez un mode de configuration avant de continuer.', + onboarding_error_base_url_required: 'L\'URL de base est requise pour les points de terminaison personnalisés.', + onboarding_probe_test_button: 'Tester la connexion', + onboarding_probe_probing: 'Test de connexion…', + onboarding_probe_ok: 'Connecté. {n} modèle(s) disponible(s).', + onboarding_probe_error_generic: 'Impossible d\'atteindre l\'URL de base configurée.', + onboarding_probe_error_invalid_url: 'L\'URL de base doit commencer par http:// ou https://.', + onboarding_probe_error_dns: 'Impossible de résoudre l\'hôte. Vérifiez l\'URL ou utilisez l\'adresse IP de l\'hôte.', + onboarding_probe_error_connect_refused: 'Connexion refusée : le serveur ne fonctionne peut-être pas à cette adresse. Depuis Docker, essayez l’adresse IP de l’hôte au lieu de localhost.', + onboarding_probe_error_timeout: 'Le point final n’a pas répondu à temps. Vérifiez que le serveur est en cours d\'exécution et que l\'URL est correcte.', + onboarding_probe_error_http_4xx: 'Le point de terminaison a renvoyé une erreur client. Vérifiez l\'authentification et le chemin de l\'URL (se termine généralement par /v1).', + onboarding_probe_error_http_5xx: 'Le point de terminaison a renvoyé une erreur de serveur. Vérifiez les journaux du serveur LM Studio / Ollama.', + onboarding_probe_error_parse: 'Le point de terminaison n’a pas renvoyé une liste de modèles sous la forme attendue. Vérifiez que l\'URL pointe vers la racine de l\'API compatible OpenAI.', + onboarding_probe_error_unreachable: 'Impossible d\'atteindre l\'URL de base configurée.', + onboarding_error_probe_failed: 'Impossible de valider l\'URL de base configurée.', + onboarding_error_workspace_required: 'Un espace de travail est requis.', + onboarding_error_model_required: 'Un modèle est requis.', + onboarding_complete: 'Intégration terminée', + error_prefix: 'Erreur:', + not_available: 'N / A', + never: 'jamais', + add: 'Ajouter', + add_failed: 'Échec de l\'ajout :', + remove_failed: 'Échec de la suppression :', + switch_failed: 'Échec du changement :', + name_required: 'Le nom est requis', + content_required: 'Le contenu est requis', + view: 'Voir', + dismiss: 'Rejeter', + disable: 'Désactiver', + cron_no_jobs: 'Aucune tâche planifiée trouvée.', + cron_status_off: 'désactivé', + cron_status_paused: 'en pause', + cron_status_error: 'erreur', + cron_status_active: 'actif', + cron_status_running: 'courir\u2026', + cron_status_needs_attention: 'a besoin d\'attention', + cron_attention_desc: 'Cette tâche récurrente n\'a pas de prochaine exécution. Le planificateur n\'a peut-être pas réussi à calculer sa prochaine exécution.', + cron_attention_croniter_hint: 'Il se peut que le package croniter soit manquant dans le runtime Gateway. Redémarrez la passerelle avec la prise en charge de cron, puis reprenez ce travail.', + cron_attention_resume: 'Reprendre et recalculer', + cron_jobs_project: 'Emplois Cron', + cron_attention_run_once: 'Cours une fois maintenant', + cron_attention_copy_diagnostics: 'Copier les diagnostics', + cron_diagnostics_copied: 'Diagnostics Cron copiés', + cron_next: 'Suivant', + cron_last: 'Dernier', + cron_run_now: 'Courez maintenant', + cron_pause: 'Pause', + cron_resume: 'CV', + cron_job_name_placeholder: 'Nom du travail', + cron_schedule_placeholder: 'Calendrier', + cron_prompt_placeholder: 'Rapide', + cron_last_output: 'Dernière sortie', + cron_all_runs: 'Toutes les courses', + cron_hide_runs: 'Masquer les courses', + cron_no_runs_yet: '(pas encore de courses)', + cron_schedule_required_example: 'Un horaire est requis (par exemple "0 9 * * *" ou "toutes les 1h")', + cron_schedule_required: 'Un horaire est requis', + cron_prompt_required: 'Une invite est requise', + cron_job_created: 'Emploi créé', + cron_duplicate: 'Double', + cron_duplicated: 'Tâche dupliquée (en pause)', + cron_job_triggered: 'Tâche déclenchée', + cron_job_paused: 'Tâche suspendue', + cron_job_resumed: 'Travail repris', + cron_job_updated: 'Emploi mis à jour', + cron_delete_confirm_title: 'Supprimer la tâche cron', + cron_delete_confirm_message: 'Cela ne peut pas être annulé.', + cron_job_deleted: 'Travail supprimé', + status_failed: 'échoué', + status_completed: 'complété', + todos_no_active: 'Aucune liste de tâches active dans cette session.', + clear_conversation_title: 'Conversation claire', + clear_conversation_message: 'Effacer tous les messages ? Cela ne peut pas être annulé.', + clear_failed: 'Échec de la suppression :', + skills_no_match: 'Aucune compétence ne correspond.', + linked_files: 'Fichiers liés', + skill_load_failed: 'Impossible de charger la compétence :', + skill_file_load_failed: 'Impossible de charger le fichier :', + skill_name_required: 'Le nom de la compétence est requis', + skill_updated: 'Compétence mise à jour', + skill_created: 'Compétence créée', + skill_deleted: 'Compétence supprimée', + skill_delete_confirm: 'Supprimer la compétence "{0}" ?', + skills_empty_title: 'Sélectionnez une compétence', + skills_empty_sub: 'Choisissez une compétence dans la barre latérale pour afficher son contenu ou créez-en une nouvelle.', + skills_edit: 'Modifier', + skills_delete: 'Supprimer', + skills_back_to: 'Retour à {0}', + tasks_empty_title: 'Sélectionnez une tâche planifiée', + tasks_empty_sub: 'Choisissez une tâche dans la barre latérale pour afficher ses détails et ses exécutions, ou créez-en une nouvelle.', + workspaces_empty_title: 'Sélectionnez un espace', + workspaces_empty_sub: 'Choisissez un espace dans la barre latérale pour afficher ses fichiers et paramètres, ou ajoutez-en un nouveau.', + profiles_empty_title: 'Sélectionnez un profil', + profiles_empty_sub: 'Choisissez un profil d\'agent dans la barre latérale pour afficher et modifier ses paramètres, ou créez-en un nouveau.', + memory_notes_label: 'mémoire (notes)', + memory_saved: 'Mémoire sauvegardée', + my_notes: 'Mes notes', + user_profile: 'Profil utilisateur', + no_notes_yet: 'Aucune note pour l\'instant.', + no_profile_yet: 'Pas encore de profil.', + workspace_choose_path: 'Choisir le chemin de l\'espace de travail', + workspace_choose_path_meta: 'Ajoutez un chemin validé et changez cette conversation', + workspace_manage: 'Gérer les espaces de travail', + workspace_manage_meta: 'Ouvrez le panneau Espaces', + workspace_use_title: 'Utiliser dans la session en cours', + workspace_use: 'Utiliser', + workspace_add_path_placeholder: 'Ajouter un chemin d\'accès à l\'espace de travail (par exemple /home/user/my-project)', + workspace_paths_validated_hint: 'Les chemins sont validés en tant que répertoires existants avant d\'être enregistrés.', + workspace_drag_hint: 'Faites glisser pour réorganiser', + workspace_reorder_failed: 'Échec de la réorganisation', + workspace_added: 'Espace de travail ajouté', + workspace_renamed: 'Espace de travail renommé', + workspace_remove_confirm_title: 'Supprimer l\'espace de travail', + workspace_removed: 'Espace de travail supprimé', + workspace_switch_prompt_title: 'Changer d\'espace de travail', + workspace_switch_prompt_message: 'Entrez un chemin d’accès absolu à l’espace de travail vers lequel ajouter et basculer cette conversation.', + workspace_switch_prompt_confirm: 'Changer', + workspace_switch_prompt_placeholder: '/Utilisateurs/vous/projet', + workspace_not_added: 'L\'espace de travail n\'a pas été ajouté', + workspace_already_saved: 'Espace de travail déjà enregistré : choisissez-le dans la liste', + workspace_busy_switch: 'Impossible de changer d\'espace de travail pendant que l\'agent est en cours d\'exécution', + discard_file_edits_title: 'Supprimer les modifications du fichier ?', + discard_file_edits_message: 'Changer d’espace de travail supprimera les modifications de fichiers non enregistrées dans l’aperçu.', + profiles_no_profiles: 'Aucun profil trouvé.', + profile_api_keys_configured: 'Clés API configurées', + profile_gateway_running: 'Passerelle en cours d\'exécution', + profile_gateway_stopped: 'Passerelle arrêtée', + profile_active: 'ACTIF', + profile_no_configuration: 'Aucune configuration', + profile_use: 'Utiliser', + profile_switch_title: 'Passer à ce profil', + profile_delete_title: 'Supprimer ce profil', + profile_default_label: '(défaut)', + profile_name_placeholder: 'Nom du profil (minuscules, a-z 0-9 tirets)', + profile_clone_label: 'Cloner la configuration du profil actif', + profile_base_url_placeholder: 'URL de base (facultatif, par exemple http://localhost:11434)', + profile_api_key_placeholder: 'Clé API (facultatif)', + manage_profiles: 'Gérer les profils', + profiles_load_failed: 'Échec du chargement des profils', + profile_name_rule: 'Lettres minuscules, chiffres, traits d\'union et traits de soulignement uniquement', + profile_base_url_rule: 'L\'URL de base doit commencer par http:// ou https://', + profile_delete_confirm_message: 'Toutes les sessions, configurations, compétences et mémoire de ce profil seront définitivement supprimées. Cela ne peut pas être annulé.', + active_conversation_none: 'Aucune conversation active sélectionnée.', + settings_unsaved_changes: 'Vous avez des modifications non enregistrées.', + sign_out_failed: 'Échec de la déconnexion :', + disable_auth_confirm_title: 'Désactiver la protection par mot de passe', + disable_auth_confirm_message: 'Tout le monde pourra accéder à cette instance.', + auth_disabled: 'Authentification désactivée – protection par mot de passe supprimée', + disable_auth_failed: 'Échec de la désactivation de l\'authentification :', + skill_name: 'Nom', + skill_category: 'Catégorie', + skill_category_placeholder: 'Facultatif, par ex. développeurs', + skill_content: 'Contenu SKILL.md', + skill_content_placeholder: 'Frontmatter YAML + corps de démarque', + skill_rename_not_supported: 'Renommer une compétence n\'est pas pris en charge. Créez une nouvelle compétence et supprimez l\'ancienne pour la renommer.', + skill_metadata: 'Métadonnées', + cron_name_label: 'Nom', + cron_name_placeholder: 'Facultatif', + cron_schedule_label: 'Calendrier', + cron_schedule_hint: 'Utilisez « toutes les heures » ou une expression cron pour les tâches récurrentes. Les durées nues comme « 30 m » s\'exécutent une fois.', + cron_schedule_once_warning: 'Les formulaires de durée tels que « 30 m » s\'exécutent une fois et sont supprimés après l\'exécution. Utilisez « tous les 30 mois » pour conserver une tâche récurrente.', + cron_prompt_label: 'Rapide', + cron_deliver_label: 'Livrer la sortie à', + cron_deliver_local: 'Local (enregistrer la sortie uniquement)', + cron_profile_label: 'Profil', + cron_profile_server_default: 'serveur par défaut', + cron_profile_server_default_hint: 'Utilise le profil par défaut du serveur WebUI au moment de l\'exécution. Les tâches existantes sans profil conservent ce comportement hérité.', + cron_toast_notifications_label: 'Toasts d’achèvement', + cron_toast_notifications_hint: 'Montrez un toast lorsque ce cron se termine. Le badge Tâches et le marqueur de nouvelle exécution sont toujours mis à jour lorsque cette option est désactivée.', + cron_toast_notifications_enabled: 'Activé', + cron_toast_notifications_disabled: 'Désactivé', + cron_skills_label: 'Compétences', + cron_skills_placeholder: 'Ajouter des compétences (facultatif)…', + cron_skills_edit_hint: 'La liste de compétences n\'est pas modifiable après la création.', + workspace_name_label: 'Nom', + workspace_name_placeholder: 'Nom convivial facultatif', + workspace_path_label: 'Chemin', + workspace_path_required: 'Le chemin est obligatoire', + workspace_path_readonly: 'Le chemin ne peut pas être modifié. Renommer uniquement.', + workspace_new_title: 'Nouvel espace', + profile_name_label: 'Nom', + profile_base_url_label: 'URL de base', + profile_api_key_label: 'Clé API', + cmd_yolo: 'Basculer le mode YOLO (ignorer les approbations)', + yolo_no_session: 'Aucune session active', + yolo_enabled: '⚡ Mode YOLO activé – les approbations ont ignoré cette session', + yolo_disabled: 'Mode YOLO désactivé', + yolo_pill_label: 'YOLO', + yolo_pill_title_active: 'Mode YOLO actif — cliquez pour désactiver', + approval_skip_all: '⚡ Passer toute cette session', + approval_skip_all_title: 'Ignorer toutes les invites d\'approbation pour cette session', + composer_send: 'Envoyer un message', + composer_queue: 'Message de file d\'attente', + composer_interrupt: 'Interrompre et envoyer', + composer_steer: 'Piloter la réponse actuelle', + composer_stop: 'Arrêter la génération', + composer_disabled_clarify: 'Répondre à la demande de précisions', + composer_disabled_compression: 'En attendant la fin de la compression', + composer_disabled_empty: 'Tapez un message à envoyer', + composer_mobile_workspace: 'Espace de travail', + composer_mobile_model: 'Modèle', + composer_mobile_reasoning: 'Raisonnement', + composer_mobile_context: 'Contexte', + media_audio_label: 'Audio', + media_svg_label: 'Diagramme', + media_video_label: 'Vidéo', + csv_loading: 'Chargement du fichier CSV', + csv_too_large: 'Fichier CSV trop volumineux pour le rendu en ligne', + csv_no_data: 'Le fichier CSV ne contient pas suffisamment de données pour être affiché sous forme de tableau', + csv_error: 'Échec du chargement du fichier CSV', + csv_header_note: 'Première ligne affichée comme en-tête du tableau', + excalidraw_loading: 'Diagramme de chargement', + excalidraw_too_large: 'Fichier Excalidraw trop volumineux pour le rendu en ligne', + excalidraw_invalid: 'Format de fichier Excalidraw invalide', + excalidraw_error: 'Échec du chargement du fichier Excalidraw', + excalidraw_label: 'Diagramme', + excalidraw_download: 'Télécharger', + excalidraw_empty: 'Diagramme vide', + excalidraw_render_error: 'Échec du rendu du diagramme', + excalidraw_simplified: 'Aperçu SVG simplifié — pas identique au pixel au canevas Excalidraw', + checkpoint_title: 'Points de contrôle', + checkpoint_empty: 'Aucun point de contrôle trouvé pour cet espace de travail.', + checkpoint_loading: 'Chargement des points de contrôle…', + checkpoint_error: 'Échec du chargement des points de contrôle', + checkpoint_date: 'Date', + checkpoint_message: 'Message', + checkpoint_files: 'Fichiers', + checkpoint_view_diff: 'Voir la différence', + checkpoint_restore: 'Restaurer', + checkpoint_restore_confirm_title: 'Restaurer le point de contrôle ?', + checkpoint_restored: 'Point de contrôle restauré', + checkpoint_diff_title: 'Changements au point de contrôle', + checkpoint_diff_no_changes: 'Aucune différence trouvée entre ce point de contrôle et l\'espace de travail actuel.', + } }; // Active locale — defaults to English; overridden by loadLocale() at boot. From 099fdaf012253596e7959d6b5bcf650771b47b88 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Tue, 12 May 2026 07:47:21 -0600 Subject: [PATCH 25/28] fix(ui): stabilize chat bottom scrolling on iPhone PWA --- static/index.html | 4 +- static/style.css | 9 ++-- static/ui.js | 44 ++++--------------- ...test_2111_ios_pwa_bottom_scroll_stutter.py | 28 ++++++------ tests/test_issue677.py | 9 ++-- tests/test_session_jump_buttons.py | 3 +- 6 files changed, 37 insertions(+), 60 deletions(-) diff --git a/static/index.html b/static/index.html index 714f8564..8a530d16 100644 --- a/static/index.html +++ b/static/index.html @@ -304,9 +304,10 @@
-
+
+
+
diff --git a/static/style.css b/static/style.css index 7e87920f..5f2b537b 100644 --- a/static/style.css +++ b/static/style.css @@ -779,14 +779,15 @@ .workspace-toggle-btn.active{color:var(--accent-text);border-color:var(--accent-bg);background:var(--accent-bg);} .workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;} .chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);} + .messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;} .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;} - /* sticky-first-child: button is early in .messages so its natural position is above viewport; sticky+bottom pins it there when visible */ - .scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;} + /* Overlay scroll controls so they do not affect the transcript's native scroll geometry. */ + .scroll-to-bottom-btn{position:absolute;right:20px;bottom:16px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;} .scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);} .session-jump-btn__text{display:none;} - .session-jump-btn{position:sticky;align-self:flex-end;flex:0 0 32px;min-height:32px;margin-right:20px;height:32px;border-radius:999px;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 11px;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:11;transition:color .12s,border-color .12s,background .12s,opacity .12s,transform .12s;} + .session-jump-btn{position:absolute;right:20px;height:32px;border-radius:999px;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 11px;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:11;transition:color .12s,border-color .12s,background .12s,opacity .12s,transform .12s;} .session-jump-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);transform:translateY(-1px);} - .session-jump-btn--start{top:16px;margin-bottom:-36px;} + .session-jump-btn--start{top:16px;} .messages.session-nav-enabled .scroll-to-bottom-btn{width:auto;min-width:32px;border-radius:999px;font-size:12px;font-weight:600;gap:5px;padding:0 11px;} .messages.session-nav-enabled .scroll-to-bottom-btn:hover{transform:translateY(-1px);} .messages.session-nav-enabled .session-jump-btn__text{display:inline;} diff --git a/static/ui.js b/static/ui.js index 3b305474..d071d93e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1657,36 +1657,6 @@ let _messageUserUnpinned=false; let _bottomSettleToken=0; const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; const MESSAGE_UPWARD_INTENT_MS=450; -function _isIosStandalonePwa(){ - try{ - const ua=navigator.userAgent||''; - const isIosDevice=/iP(?:hone|ad|od)/i.test(ua) - || (navigator.platform==='MacIntel'&&navigator.maxTouchPoints>1); - if(!isIosDevice) return false; - const standalone=(typeof navigator!=='undefined'&&navigator.standalone===true) - || (typeof window.matchMedia==='function'&&window.matchMedia('(display-mode: standalone)').matches) - || (typeof window.matchMedia==='function'&&window.matchMedia('(display-mode: fullscreen)').matches); - if(!standalone) return false; - return typeof window.matchMedia!=='function' || window.matchMedia('(pointer: coarse)').matches; - }catch(_){ - return false; - } -} -function _messagePanePreferredBottomScrollTop(el){ - if(!el) return 0; - const maxTop=Math.max(0,el.scrollHeight-el.clientHeight); - if(maxTop<=1) return maxTop; - return _isIosStandalonePwa()?maxTop-1:maxTop; -} -function _maybeInsetIosStandaloneBottomEdge(el, top){ - if(!el||!_isIosStandalonePwa()) return; - const preferredTop=_messagePanePreferredBottomScrollTop(el); - if(preferredTop<=0||top{ setTimeout(()=>{_programmaticScroll=false;},0); }); -} function _cancelBottomSettle(){ _bottomSettleToken++; } function _recordNonMessageScrollIntent(e){ const el=document.getElementById('messages'); @@ -1702,12 +1672,14 @@ function _recordNonMessageScrollIntent(e){ // input event so later scrollTop decreases caused by layout/windowing do // not masquerade as user intent and strand live streaming away from bottom. _lastMessageUpwardIntentMs=performance.now(); - _messageUserUnpinned=true; // User is intentionally moving in the transcript. Cancel any delayed // scrollToBottom settling that was scheduled by session-load/layout growth. _cancelBottomSettle(); - _nearBottomCount=0; - _scrollPinned=false; + if(typeof e.deltaY==='number'&&e.deltaY<0){ + _messageUserUnpinned=true; + _nearBottomCount=0; + _scrollPinned=false; + } } } function _recentMessageUpwardIntent(){ @@ -1746,9 +1718,9 @@ if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetS } else { _nearBottomCount=0; _scrollPinned=false; } if(_scrollPinned) _messageUserUnpinned=false; } // #1360 - _maybeInsetIosStandaloneBottomEdge(el, top); const btn=$('scrollToBottomBtn'); - if(btn) btn.style.display=_scrollPinned?'none':'flex'; + const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80; + if(btn) btn.style.display=showBottomButton?'flex':'none'; if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton(); // Prefetch older messages before the reader hits the hard top. Prepending // then preserving scrollTop is seamless only if there is runway left for @@ -2040,7 +2012,7 @@ function _setMessageScrollToBottom(){ const el=$('messages'); if(!el) return; _programmaticScroll=true; - el.scrollTop=_messagePanePreferredBottomScrollTop(el); + el.scrollTop=el.scrollHeight; _lastScrollTop=el.scrollTop; _nearBottomCount=2; _scrollPinned=true; diff --git a/tests/test_2111_ios_pwa_bottom_scroll_stutter.py b/tests/test_2111_ios_pwa_bottom_scroll_stutter.py index ca58f7f6..02f4eae6 100644 --- a/tests/test_2111_ios_pwa_bottom_scroll_stutter.py +++ b/tests/test_2111_ios_pwa_bottom_scroll_stutter.py @@ -1,22 +1,22 @@ from pathlib import Path REPO = Path(__file__).parent.parent +INDEX_HTML = (REPO / 'static' / 'index.html').read_text(encoding='utf-8') +STYLE_CSS = (REPO / 'static' / 'style.css').read_text(encoding='utf-8') UI_JS = (REPO / 'static' / 'ui.js').read_text(encoding='utf-8') -def test_ios_standalone_detection_helper_exists(): - assert 'function _isIosStandalonePwa()' in UI_JS - assert "window.matchMedia('(display-mode: standalone)').matches" in UI_JS - assert 'navigator.standalone===true' in UI_JS +def test_scroll_controls_are_overlays_outside_messages_scroller(): + shell = INDEX_HTML.index('
') + scroller = INDEX_HTML.index('
') + assert shell < INDEX_HTML.index('id="scrollToBottomBtn"') < scroller + assert '.messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;}' in STYLE_CSS + assert '.scroll-to-bottom-btn{position:absolute;' in STYLE_CSS + assert '.session-jump-btn{position:absolute;' in STYLE_CSS -def test_message_bottom_prefers_one_pixel_inset_on_ios_pwa(): - assert 'function _messagePanePreferredBottomScrollTop(el)' in UI_JS - assert 'return _isIosStandalonePwa()?maxTop-1:maxTop;' in UI_JS - assert 'el.scrollTop=_messagePanePreferredBottomScrollTop(el);' in UI_JS - - -def test_ios_pwa_bottom_edge_guard_installed_on_messages_pane(): - assert 'function _maybeInsetIosStandaloneBottomEdge(el, top)' in UI_JS - assert 'if(preferredTop<=0||top80;' in scroll_listener + assert '_isIosStandalonePwa' not in UI_JS + assert '_messagePanePreferredBottomScrollTop' not in UI_JS diff --git a/tests/test_issue677.py b/tests/test_issue677.py index ff5dfbf6..a450e1d2 100644 --- a/tests/test_issue677.py +++ b/tests/test_issue677.py @@ -112,13 +112,14 @@ class TestScrollPinningFix: "style.css must define .scroll-to-bottom-btn styles (#677)" ) - def test_scroll_to_bottom_button_is_sticky(self): - """Scroll-to-bottom button must use position:sticky so it stays visible (#677).""" + def test_scroll_to_bottom_button_is_overlayed(self): + """Scroll-to-bottom button stays visible as an overlay outside transcript layout (#677).""" btn_css_pos = STYLE_CSS.find(".scroll-to-bottom-btn") assert btn_css_pos != -1 btn_css = STYLE_CSS[btn_css_pos:btn_css_pos + 300] - assert "sticky" in btn_css, ( - ".scroll-to-bottom-btn must use position:sticky to stay at bottom of viewport (#677)" + assert "position:absolute" in btn_css, ( + ".scroll-to-bottom-btn must be an overlay so it stays visible without " + "participating in transcript scroll layout (#677)" ) def test_scroll_listener_hides_button_when_pinned(self): diff --git a/tests/test_session_jump_buttons.py b/tests/test_session_jump_buttons.py index a0dd85d4..274b6104 100644 --- a/tests/test_session_jump_buttons.py +++ b/tests/test_session_jump_buttons.py @@ -32,7 +32,8 @@ def test_session_jump_buttons_are_opt_in_and_keep_existing_bottom_button(): assert "session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked" in PANELS_JS scroll_listener = UI_JS[UI_JS.index("el.addEventListener('scroll'") : UI_JS.index("})();", UI_JS.index("el.addEventListener('scroll'"))] - assert "if(btn) btn.style.display=_scrollPinned?'none':'flex'" in scroll_listener + assert "const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80" in scroll_listener + assert "if(btn) btn.style.display=showBottomButton?'flex':'none'" in scroll_listener assert "!_isSessionJumpButtonsEnabled()||_scrollPinned" not in UI_JS From 7116c680dfc165e830d8cbbdddc287a4db385f6d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 12 May 2026 16:14:47 +0000 Subject: [PATCH 26/28] =?UTF-8?q?stage-344:=20maintainer=20fix=20for=20#21?= =?UTF-8?q?42=20fr=20locale=20=E2=80=94=20add=20LOCALES=20tuple=20entries?= =?UTF-8?q?=20+=20=5FLOGIN=5FLOCALE=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2142 (legeantbleu) added the fr locale to static/i18n.js but didn't update: 1. tests/test_issue1488_composer_voice_buttons.py: two TestComposerVoiceButtonI18n + TestVoiceModePreferenceGate LOCALES tuples needed 'fr' 2. api/routes.py: _LOGIN_LOCALE needed an 'fr' block so the login page localizes for French users (issue #1442 parity contract) 3. tests/test_login_locale_parity.py: the test asserting 'fr' falls-back-to-'en' is inverted — fr now resolves to fr, with sibling assertions for fr-FR and fr-CA Mirrors the stage-340 fix for the it locale (PR #2067 → maintainer adds tuple entries). 46/46 i18n tests pass after fix. --- api/routes.py | 9 +++++++++ tests/test_issue1488_composer_voice_buttons.py | 4 ++-- tests/test_login_locale_parity.py | 4 +++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/api/routes.py b/api/routes.py index 2aaac2bf..31d28286 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1928,6 +1928,15 @@ _LOGIN_LOCALE = { "invalid_pw": "Invalid password", "conn_failed": "Connection failed", }, + "fr": { + "lang": "fr-FR", + "title": "Se connecter", + "subtitle": "Entrez votre mot de passe pour continuer", + "placeholder": "Mot de passe", + "btn": "Se connecter", + "invalid_pw": "Mot de passe invalide", + "conn_failed": "\u00c9chec de la connexion", + }, "es": { "lang": "es-ES", "title": "Iniciar sesi\u00f3n", diff --git a/tests/test_issue1488_composer_voice_buttons.py b/tests/test_issue1488_composer_voice_buttons.py index 787bf8a8..f1e1a78e 100644 --- a/tests/test_issue1488_composer_voice_buttons.py +++ b/tests/test_issue1488_composer_voice_buttons.py @@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n: "voice_mode_toggle_active", ) - LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") + LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") def test_legacy_voice_toggle_key_removed(self): """The old key whose string was 'Voice input' caused the duplicate- @@ -171,7 +171,7 @@ class TestComposerVoiceButtonI18n: class TestVoiceModePreferenceGate: """boot.js must hide btnVoiceMode by default, surface it via Preferences.""" - LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") + LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") def test_voice_mode_pref_is_localstorage_backed(self): """The pref reads from localStorage key 'hermes-voice-mode-button'.""" diff --git a/tests/test_login_locale_parity.py b/tests/test_login_locale_parity.py index 77392f96..313e2632 100644 --- a/tests/test_login_locale_parity.py +++ b/tests/test_login_locale_parity.py @@ -293,8 +293,10 @@ def test_login_locale_resolver_handles_new_locales(): assert _resolve_login_locale_key("pt-PT") == "pt" assert _resolve_login_locale_key("ko") == "ko" assert _resolve_login_locale_key("ko-KR") == "ko" + assert _resolve_login_locale_key("fr") == "fr" + assert _resolve_login_locale_key("fr-FR") == "fr" + assert _resolve_login_locale_key("fr-CA") == "fr" # Unknown locale still falls back to en. - assert _resolve_login_locale_key("fr") == "en" assert _resolve_login_locale_key("xx-YY") == "en" From 4ab6cd68ad7f3b308806ff813ee4efa048a4018e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 12 May 2026 16:15:56 +0000 Subject: [PATCH 27/28] =?UTF-8?q?docs:=20CHANGELOG=20stage-344=20=E2=80=94?= =?UTF-8?q?=20close=20v0.51.50,=20open=20Unreleased=20for=2016-PR=20contri?= =?UTF-8?q?butor=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6639e9ab..5240b07b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,45 @@ ## [Unreleased] +### Added + +- **PR #2130** by @dso2ng — Lazy lineage-report fetch on sidebar segment-badge expand. The sidebar already showed `N segments` for collapsed compression lineage rows (refs #1906, #1943) and the backend report endpoint is now shipped (refs #2012), but some rows only had a backend `_compression_segment_count` from `/api/sessions` while the browser hadn't materialized the older segment rows — clicking the badge couldn't reveal the full bounded list. Adds a small per-sidebar-cache lineage-report cache/inflight map in `static/sessions.js`, invalidates it on each fresh `/api/sessions` refresh, and on expand fetches `GET /api/session/lineage/report?session_id=` only when `_sessionSegmentCount(s)` exceeds the locally-materialized `_lineage_segments` count. Merges returned report `segments` by `session_id` with existing client segments, skipping the visible tip and `child_session` rows. Leaves report `children` out of the compression-segment list so subagent/fork child semantics remain separate. 132-line regression suite covering fetch-needed detection, report-segment merging/dedup, endpoint construction, and inflight cache de-duping. + +- **PR #2142** by @legeantbleu — French (`fr`) locale. ~938 UI strings translated via Google Translate then sanitized for JS string escaping. Inserted at the end of `static/i18n.js`'s `LOCALES` map (insertion-order convention used by every locale since `it` landed). Stage-344 maintainer fix added the matching tuple entries in `tests/test_issue1488_composer_voice_buttons.py:TestComposerVoiceButtonI18n.LOCALES` + sibling `TestVoiceModePreferenceGate.LOCALES`, plus the matching `_LOGIN_LOCALE['fr']` block in `api/routes.py` so the login page localizes for French users (issue #1442 parity contract), plus an inverted `_resolve_login_locale_key('fr')` assertion in `tests/test_login_locale_parity.py` that previously assumed fr falls back to en. Mirrors the stage-340 fix for the `it` locale (PR #2067). + ### Fixed -- **PR #2137** by @franksong2702 — The login page `/health` connectivity probe now sends same-origin credentials instead of forcing `credentials: "omit"`, so deployments protected by Cloudflare Access or another same-origin access proxy can pass their access cookie through before WebUI decides the service is reachable. This keeps the probe mount-relative and does not change WebUI password auth. +- **PR #2120** by @Michaelyklam (closes #2103) — Daily Tokens chart no longer overflows its card on 90/365 day ranges. Adds `_bucketDailyTokensForChart()` in `static/panels.js` that keeps ≤30 rows per-day and buckets longer ranges into summed chart rows (90→45 bars at 2-day buckets, 365→46 bars at 8-day buckets, ≤52 ceiling). Updates the Daily Tokens render loop to use bucketed chart rows, date-range labels, and summed tooltip values. Switched chart columns to shrink-safe `minmax(0,1fr)` so the bars stay inside the card. Backend `/api/insights` payload unchanged. 130-line regression suite covering short-range preservation, long-range bounding, label/title shape on bucketed rows, render-loop usage, and shrink-safe CSS. + +- **PR #2121** by @Michaelyklam (refs #2104) — Token Breakdown + Models row stacks on mobile instead of forcing horizontal page overflow. New `insights-usage-grid` class wraps the row with a scoped `@media (max-width: 640px)` rule that flips it to `grid-template-columns: 1fr`. Contains remaining model-table overflow inside the card. 27-line regression suite covering the mobile breakpoint, single-column layout, contained `overflow-x`, and presence of the scoped rule. + +- **PR #2123** by @Michaelyklam (closes #2112) — Portuguese (`pt`) locale parity: 5 missing session-management keys (bulk delete/archive, select mode, select all, selected count, no-selection text) added so Portuguese users stop silently falling back to English. Extended `tests/test_login_locale_parity.py` with a session-management key parity check across all locale blocks. + +- **PR #2125** by @Michaelyklam (closes #2093) — Renamed `_patch_skill_home_modules` → `patch_skill_home_modules` in `api/profiles.py` since the helper is imported by streaming code and asserted by tests across modules. Updated streaming import/fallback/call sites in `api/streaming.py` and the env-lock regression test expectations. Expanded `api/compression_anchor.py`'s module docstring to explain manual vs automatic compression anchoring and `auto_compression=True` behavior. Documentation/rename-only — no runtime behavior change. + +- **PR #2128** by @franksong2702 (closes #2087) — Manual `/compress` no longer fails behind reverse proxies that time out long synchronous requests. Adds `POST /api/session/compress/start` (start or reuse an in-process manual compression job keyed by `session_id`) + `GET /api/session/compress/status?session_id=...` (poll `running`/`done`/`error`/`idle`). Reuses the existing `_handle_session_compress` implementation inside the worker so the save path, provider resolution, sanitization, and the legacy synchronous endpoint stay aligned. Adds a stream-state guard before save so a compression worker can't overwrite a session that started another stream while compression was running. 10-minute cleanup for terminal job results, with successful `done` payloads released after first status consumption. `static/commands.js` `/compress` and `/compact` now start, poll, and apply the saved compressed session; session-load resume wiring picks up in-flight compression on page reload. + +- **PR #2129** by @Michaelyklam (closes #2092) — `_purgeStaleInflightEntries()` now iterates `INFLIGHT` keys and explicitly drops ids absent from the current session list. Pre-fix the cleanup only removed entries for sessions still present in `_allSessions` and marked non-streaming, so deleted/archived/filtered-out sessions left ghost entries indefinitely. Preserves still-streaming sessions. 124-line regression suite covering absent/present-non-streaming/present-streaming cases. + +- **PR #2135** by @franksong2702 (closes #2126, refs #2131) — `/api/models/live?provider=custom:` now only returns models from the requested named provider entry instead of every `custom_providers[].model`. Direct `/v1/models` fallback uses the matched named provider's `base_url`+`api_key` pair instead of the active profile's `model.base_url`/`model.api_key`. `custom:` reads only the matching named entry; bare `custom` reads only unnamed entries. Includes model IDs from both singular `model` and plural `models` config forms. Cache key behavior preserved (already provider-scoped). Regression coverage for named-provider scoping, bare-custom scoping, and direct fetch endpoint/key selection. + +- **PR #2137** by @franksong2702 (closes #2122) — Login page health probe now sends `credentials: 'same-origin'` instead of `credentials: 'omit'`. Cloudflare Access and similar same-origin reverse proxies need the access cookie to reach the proxy, so the prior omit caused WebUI to falsely disable login before `/health` ever resolved. Keeps the health URL mount-relative (`health`) for subpath deployments. Static regression test pins same-origin credentials and forbids the omit variant. + +- **PR #2138** by @dobby-d-elf — Live Hermes WebUI chats no longer get stuck with `Error: Path does not exist: ...` when the session points at a deleted workspace. Workspace fallback now looks up the live `DEFAULT_WORKSPACE` instead of using a stale import-time snapshot. Old sessions with deleted implicit workspaces are repaired to the current valid workspace during chat start, so the next send recovers instead of erroring. 71-line regression suite for both the stale-fallback and missing-session-workspace recovery paths. + +- **PR #2139** by @Michaelyklam (refs #2097) — Turn-journal terminal-collision audit slice. `derive_turn_journal_states()` now returns `(states, terminal_collisions)`; collisions carry the `turn_id` plus terminal events in timestamp order when a turn records more than one terminal event (completed + interrupted both fire). Latest-by-timestamp derived state behavior preserved for existing callers; session recovery audit and existing tests updated to unpack the new tuple. Audit-only: no multi-process append safety in this PR. + +- **PR #2140** by @franksong2702 (closes #2133) — WebUI fallback activation now passes `api_key` and `key_env` in the normalized fallback entry to `AIAgent`, matching what the CLI path preserves. Hermes Agent fallback resolution already knew how to use these — WebUI was dropping them, leaving env-backed fallback providers unauthenticated after a primary provider 401. Legacy single-dict `fallback_model` and list-form `fallback_providers` selection behavior unchanged. + +- **PR #2141** by @franksong2702 (closes #2102) — Settings → System header no longer clips off the right edge on phones. Section header now stacks vertically under the existing Settings mobile breakpoint; the System update/version control group wraps to use available width; individual version badges keep their text intact while the group wraps. CSS-only change inside the existing breakpoint scope. Mobile layout static regression added. + +- **PR #2143** by @dobby-d-elf — iPhone PWA chat bottom-scroll stutter fixed. Removed the Start/End scroll controls from the transcript scroll layout — they were sticky children inside `#messages`, which on iOS momentum/elastic scrolling perturbed the scroll surface at the bottom boundary. Now the transcript is wrapped in a `.messages-shell` and the controls render as absolute overlays outside `#messages`, so `#messages` is back to a plain native scrolling container. Adds a small visibility dead zone for the down-arrow button so elastic bottom pulls don't flash the button while already at the bottom. + +- **PR #2132** by @Michaelyklam (refs #2096) — Docs-only: added `Synchronous durability design rationale` to `docs/rfcs/turn-journal.md`. Documents why submitted-event journaling stays synchronously fsync-backed today, qualitative fsync latency expectations for SSD/HDD/Docker-overlay filesystems, and maintainer benchmark guidance for measuring p50/p95/p99 append/fsync latency before any future async lifecycle journaling. + +## [v0.51.50] — 2026-05-12 — Release Z (stage-343 — single-PR — ctl.sh bash 3.2 macOS compat fix + regression test suite) + +### Fixed - **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh. From 2def05f38507b6ca2fc4509ba643e5708059dfa7 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 12 May 2026 16:37:37 +0000 Subject: [PATCH 28/28] =?UTF-8?q?stage-344:=20apply=20Opus=20SHOULD-FIX=20?= =?UTF-8?q?#1+#2=20=E2=80=94=20#2128=20multi-tab=20race=20+=20stale-done?= =?UTF-8?q?=20re-emit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) compress/status no longer pops the job entry on first read of `done` payload. Second open tab no longer sees `idle` and a stale-job toast. (2) compress/start no longer short-circuits to a stale `done` payload when re-invoked within the 10-minute TTL. Re-running /compress always starts fresh, so closing-and-reopening a tab mid-compress works correctly. Third SHOULD-FIX (#2135 cfg["model"] fallback tightening when no custom_providers entry matches) deferred to follow-up — strictly no-worse-than-master behavior. tests/test_sprint46.py 10/10 still passes. --- CHANGELOG.md | 4 ++++ api/routes.py | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5240b07b..2153acdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ - **PR #2132** by @Michaelyklam (refs #2096) — Docs-only: added `Synchronous durability design rationale` to `docs/rfcs/turn-journal.md`. Documents why submitted-event journaling stays synchronously fsync-backed today, qualitative fsync latency expectations for SSD/HDD/Docker-overlay filesystems, and maintainer benchmark guidance for measuring p50/p95/p99 append/fsync latency before any future async lifecycle journaling. +### Stage-344 maintainer fixes + +- **`api/routes.py:_handle_session_compress_start/status` (#2128 polish)** — Opus SHOULD-FIX from stage-344 review. Two related UX bugs in the new async manual-compression flow: (1) `compress/status` popped the `done` job entry on first read, which left a second open tab with `{status:"idle"}` and a "Compression job is no longer available" toast — fixed by letting the existing 10-minute TTL handle eviction so all tabs see the same terminal payload; (2) re-invoking `compress/start` within the 10-minute TTL returned the stale prior `done` payload instead of running a new compression — fixed by always dropping the existing entry and starting a fresh worker, so a user closing a tab mid-compress and re-running `/compress` on a fresh open gets a new result. Both are 1-block tweaks; existing `tests/test_sprint46.py` 10/10 still passes. The third Opus SHOULD-FIX (#2135 `cfg["model"]` fallback when `provider=custom:X` doesn't match any entry) is deferred to a follow-up — it's strictly no-worse-than-master behavior, but worth tightening to skip the URL probe when no entry matched. + ## [v0.51.50] — 2026-05-12 — Release Z (stage-343 — single-PR — ctl.sh bash 3.2 macOS compat fix + regression test suite) ### Fixed diff --git a/api/routes.py b/api/routes.py index 31d28286..65f12285 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7894,9 +7894,14 @@ def _handle_session_compress_start(handler, body): existing_payload = _manual_compression_status_payload(existing) if existing_payload.get("status") == "running": return j(handler, existing_payload) + # Stage-344 Opus SHOULD-FIX (#2128): always start fresh on re-invoke. + # The prior implementation short-circuited and returned a stale `done` + # payload for the full 10-minute TTL window when /compress/start was + # re-invoked, so a user closing the tab mid-compress and re-running + # /compress on a fresh open would get the previous result back rather + # than a new compression. Drop the entry and fall through to the + # fresh-worker path below. _MANUAL_COMPRESSION_JOBS.pop(sid, None) - if existing_payload.get("status") == "done": - return j(handler, existing_payload) job = { "session_id": sid, "focus_topic": focus_topic, @@ -7928,8 +7933,12 @@ def _handle_session_compress_status(handler, sid): if not job: return j(handler, {"ok": True, "status": "idle", "session_id": sid}) payload = _manual_compression_status_payload(job) - if payload.get("status") == "done": - _MANUAL_COMPRESSION_JOBS.pop(sid, None) + # Stage-344 Opus SHOULD-FIX (#2128): do not pop the job on first + # read of a `done` payload. The session may be open in multiple + # tabs, and the first tab's poll would otherwise leave the second + # tab with `idle` and a "Compression job is no longer available" + # toast. Let the 10-minute TTL handle eviction so all open tabs + # see the same terminal payload. return j(handler, payload)