diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ddaac..2729c7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Hermes Web UI -- Changelog +## [v0.50.87] — 2026-04-18 + +### Fixed +- **Streaming scroll override (#677)** — auto-scroll no longer hijacks your position while the AI is responding. `renderMessages()` and `appendThinking()` now call `scrollIfPinned()` during an active stream instead of `scrollToBottom()`, so scrolling up to read earlier content works correctly. Scroll re-pin threshold widened from 80px to 150px to avoid hair-trigger re-pinning on fast mouse wheels. A floating **↓ button** appears at the bottom-right of the message area when you scroll up, giving a one-click way to jump back to live output. +- **Gemini 3.x model IDs updated (#669)** — all provider model lists (`gemini`, `google`, OpenRouter fallback, GitHub Copilot, OpenCode Zen, Nous) now include the correct Gemini 3.1 Pro Preview, Gemini 3 Flash Preview, and Gemini 3.1 Flash Lite Preview model IDs alongside stable Gemini 2.5 models. The missing `gemini-3.1-flash-lite-preview` (which caused `API_KEY_INVALID` errors) is now present. `GEMINI_API_KEY` env var now also triggers native gemini provider detection. +- **Read-only workspace mount no longer crashes Docker startup (#670)** — `docker_init.bash` now checks `[ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]` before attempting `chown` or write-test on the workspace directory. `:ro` bind-mounts are silently accepted with a log message instead of calling `error_exit`. +- **UID/GID auto-detection now works in two-container setups (#668)** — `docker_init.bash` now probes `/home/hermeswebui/.hermes` and `$HERMES_HOME` (shared hermes-home volume) before falling back to `/workspace`. In Zeabur and Docker Compose two-container deployments where the hermes-agent container initializes the shared volume first, the WebUI now correctly inherits its UID/GID without manual `WANTED_UID` configuration. + ## [v0.50.86] — 2026-04-18 ### Added diff --git a/api/config.py b/api/config.py index bbe1416..8c306ba 100644 --- a/api/config.py +++ b/api/config.py @@ -436,9 +436,12 @@ _FALLBACK_MODELS = [ {"provider": "Anthropic", "id": "anthropic/claude-sonnet-4.6", "label": "Claude Sonnet 4.6"}, {"provider": "Anthropic", "id": "anthropic/claude-sonnet-4-5", "label": "Claude Sonnet 4.5"}, {"provider": "Anthropic", "id": "anthropic/claude-haiku-4-5", "label": "Claude Haiku 4.5"}, - # Google - {"provider": "Google", "id": "google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"}, - {"provider": "Google", "id": "google/gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"}, + # Google — 3.x (latest preview) + 2.5 (stable GA) + {"provider": "Google", "id": "google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"}, + {"provider": "Google", "id": "google/gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"}, + {"provider": "Google", "id": "google/gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"}, + {"provider": "Google", "id": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro"}, + {"provider": "Google", "id": "google/gemini-2.5-flash", "label": "Gemini 2.5 Flash"}, # DeepSeek {"provider": "DeepSeek", "id": "deepseek/deepseek-chat-v3-0324", "label": "DeepSeek V3"}, {"provider": "DeepSeek", "id": "deepseek/deepseek-r1", "label": "DeepSeek R1"}, @@ -501,8 +504,11 @@ _PROVIDER_MODELS = { {"id": "codex-mini-latest", "label": "Codex Mini (latest)"}, ], "google": [ - {"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"}, - {"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"}, + {"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"}, + {"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"}, + {"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"}, + {"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"}, + {"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"}, ], "deepseek": [ {"id": "deepseek-chat-v3-0324", "label": "DeepSeek V3"}, @@ -542,7 +548,7 @@ _PROVIDER_MODELS = { {"id": "gpt-4o", "label": "GPT-4o"}, {"id": "claude-opus-4.6", "label": "Claude Opus 4.6"}, {"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6"}, - {"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"}, + {"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"}, ], # OpenCode Zen — curated models via opencode.ai/zen (pay-as-you-go credits) "opencode-zen": [ @@ -571,6 +577,9 @@ _PROVIDER_MODELS = { {"id": "claude-3-5-haiku", "label": "Claude 3.5 Haiku"}, {"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"}, {"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"}, + {"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"}, + {"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"}, + {"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"}, {"id": "glm-5.1", "label": "GLM-5.1"}, {"id": "glm-5", "label": "GLM-5"}, {"id": "kimi-k2.5", "label": "Kimi K2.5"}, @@ -590,9 +599,14 @@ _PROVIDER_MODELS = { {"id": "minimax-m2.5", "label": "MiniMax M2.5"}, ], # 'gemini' is the hermes_cli provider ID for Google AI Studio + # Model IDs are bare — sent directly to: + # https://generativelanguage.googleapis.com/v1beta/openai/chat/completions "gemini": [ - {"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"}, - {"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"}, + {"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"}, + {"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"}, + {"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"}, + {"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"}, + {"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"}, ], # Mistral — prefix used in OpenRouter model IDs (mistralai/mistral-large-latest) "mistralai": [ @@ -817,6 +831,7 @@ def get_available_models() -> dict: "OPENAI_API_KEY", "OPENROUTER_API_KEY", "GOOGLE_API_KEY", + "GEMINI_API_KEY", "GLM_API_KEY", "KIMI_API_KEY", "DEEPSEEK_API_KEY", @@ -836,6 +851,8 @@ def get_available_models() -> dict: detected_providers.add("openrouter") if all_env.get("GOOGLE_API_KEY"): detected_providers.add("google") + if all_env.get("GEMINI_API_KEY"): + detected_providers.add("gemini") if all_env.get("GLM_API_KEY"): detected_providers.add("zai") if all_env.get("KIMI_API_KEY"): diff --git a/docker-compose.two-container.yml b/docker-compose.two-container.yml index 7cda227..3d6360b 100644 --- a/docker-compose.two-container.yml +++ b/docker-compose.two-container.yml @@ -46,7 +46,9 @@ services: - HERMES_WEBUI_HOST=0.0.0.0 - HERMES_WEBUI_PORT=8787 - HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp - # Match your host user's UID/GID for correct file permissions + # Match your host user's UID/GID for correct file permissions. + # In two-container setups the WebUI auto-detects UID/GID from the shared + # hermes-home volume, but you can override explicitly if needed (#668): - WANTED_UID=${UID:-1000} - WANTED_GID=${GID:-1000} # Optional: set a password for remote access diff --git a/docker_init.bash b/docker_init.bash index 0b07de4..4849374 100644 --- a/docker_init.bash +++ b/docker_init.bash @@ -59,12 +59,26 @@ it=$itdir/hermeswebui_user_uid if [ -z "${WANTED_UID+x}" ]; then if [ -f $it ]; then WANTED_UID=$(cat $it); fi fi -# Auto-detect from mounted workspace if still unset (#569). +# Auto-detect from mounted volumes if still unset (#569, #668). # On macOS, host UIDs start at 501. Using the wrong UID means the container # user cannot read the bind-mounted files, making the workspace appear empty. -# Prefer the workspace mount UID over the hardcoded default of 1024. +# In two-container setups (hermes-agent + hermes-webui), the shared hermes-home +# volume may be owned by the agent container's UID — detect from there first. if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then - # Use /workspace — the standard bind-mount point — to read the host UID. + # Priority 1: hermes-home shared volume — covers two-container Zeabur/Compose setups (#668) + for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do + if [ -d "$_probe_dir" ]; then + _detected_uid=$(stat -c '%u' "$_probe_dir" 2>/dev/null || echo "") + if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then + echo "-- Auto-detected UID: $_detected_uid (from $_probe_dir)" + WANTED_UID=$_detected_uid + break + fi + fi + done +fi +if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then + # Priority 2: /workspace bind-mount — the standard single-container mount point if [ -d "/workspace" ]; then _detected_uid=$(stat -c '%u' "/workspace" 2>/dev/null || echo "") if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then @@ -81,8 +95,22 @@ it=$itdir/hermeswebui_user_gid if [ -z "${WANTED_GID+x}" ]; then if [ -f $it ]; then WANTED_GID=$(cat $it); fi fi -# Auto-detect GID from mounted workspace to match (#569) +# Auto-detect GID from mounted volumes to match (#569, #668) if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then + # Priority 1: hermes-home shared volume + for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do + if [ -d "$_probe_dir" ]; then + _detected_gid=$(stat -c '%g' "$_probe_dir" 2>/dev/null || echo "") + if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then + echo "-- Auto-detected GID: $_detected_gid (from $_probe_dir)" + WANTED_GID=$_detected_gid + break + fi + fi + done +fi +if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then + # Priority 2: /workspace bind-mount if [ -d "/workspace" ]; then _detected_gid=$(stat -c '%g' "/workspace" 2>/dev/null || echo "") if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then @@ -211,13 +239,20 @@ rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_STATE_DIR" echo ""; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: Default workspace directory shown on first launch" if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then echo "HERMES_WEBUI_DEFAULT_WORKSPACE not set, setting to /workspace"; export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: $HERMES_WEBUI_DEFAULT_WORKSPACE" -# Use sudo for mkdir/chown — Docker may auto-create bind-mount directories as root, -# leaving them unwritable by the hermeswebui user (#357). -sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE" -sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to set owner of $HERMES_WEBUI_DEFAULT_WORKSPACE" +# Use sudo for mkdir — Docker may auto-create bind-mount directories as root (#357). +# Skip mkdir if the directory already exists (e.g. a read-only mount — #670). +if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then + sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE" +fi if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi -it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it || error_exit "Failed to verify default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE" -rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_DEFAULT_WORKSPACE" +# Only chown and write-test if the workspace is writable. Read-only bind-mounts +# (:ro) are valid — the workspace is used for browsing, not writing by the server. +if [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then + sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || echo "!! WARNING: Could not chown $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)" + it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it && rm -f $it || echo "!! WARNING: Could not write to $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)" +else + echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE is read-only — skipping chown/write check (read-only workspace is supported)" +fi echo ""; echo "===================" echo ""; echo "== Installing uv and creating a new virtual environment for hermes-webui" diff --git a/static/index.html b/static/index.html index b3d4a06..620df4d 100644 --- a/static/index.html +++ b/static/index.html @@ -180,6 +180,7 @@
+
- v0.50.86 + v0.50.87
diff --git a/static/style.css b/static/style.css index ee1af51..890edef 100644 --- a/static/style.css +++ b/static/style.css @@ -428,6 +428,9 @@ .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{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;} + /* sticky-first-child: button is first child of .messages so its natural position is above viewport; sticky+bottom:16px 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;} + .scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);} .messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;} @media(min-width:1400px){.messages-inner{max-width:1100px;}} @media(min-width:1800px){.messages-inner{max-width:1200px;}} diff --git a/static/ui.js b/static/ui.js index f913eee..7affad7 100644 --- a/static/ui.js +++ b/static/ui.js @@ -369,14 +369,16 @@ window.addEventListener('resize',()=>{ // ── Scroll pinning ────────────────────────────────────────────────────────── // When streaming, auto-scroll only if the user hasn't manually scrolled up. -// Once the user scrolls back to within 80px of the bottom, re-pin. +// Once the user scrolls back to within 150px of the bottom, re-pin. let _scrollPinned=true; (function(){ const el=document.getElementById('messages'); if(!el) return; el.addEventListener('scroll',()=>{ - const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<80; + const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<150; _scrollPinned=nearBottom; + const btn=$('scrollToBottomBtn'); + if(btn) btn.style.display=_scrollPinned?'none':'flex'; }); })(); function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);} @@ -447,6 +449,8 @@ function scrollToBottom(){ _scrollPinned=true; const el=$('messages'); if(el) el.scrollTop=el.scrollHeight; + const btn=$('scrollToBottomBtn'); + if(btn) btn.style.display='none'; } function getModelLabel(modelId){ @@ -454,7 +458,7 @@ function getModelLabel(modelId){ // Check dynamic labels first, then fall back to splitting the ID if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId]; // Static fallback for common models - const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-2.5-pro':'Gemini 2.5 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3','meta-llama/llama-4-scout':'Llama 4 Scout'}; + const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-3.1-pro-preview':'Gemini 3.1 Pro','google/gemini-3-flash-preview':'Gemini 3 Flash','google/gemini-3.1-flash-lite-preview':'Gemini 3.1 Flash Lite','google/gemini-2.5-pro':'Gemini 2.5 Pro','google/gemini-2.5-flash':'Gemini 2.5 Flash','deepseek/deepseek-chat-v3-0324':'DeepSeek V3','meta-llama/llama-4-scout':'Llama 4 Scout'}; if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId]; return modelId.split('/').pop()||'Unknown'; } @@ -1679,7 +1683,14 @@ function renderMessages(){ _assistantTurnBlocks(lastAssist).appendChild(usage); } } - scrollToBottom(); + // Only force-scroll when not actively streaming — mid-stream re-renders + // (tool completion, session switch) must not override the user's scroll position. + // scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up. + if(S.activeStreamId){ + scrollIfPinned(); + } else { + scrollToBottom(); + } // Apply syntax highlighting after DOM is built requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();renderKatexBlocks();}); // Refresh todo panel if it's currently open @@ -2051,7 +2062,7 @@ function appendThinking(text=''){ } row.className=(text&&String(text).trim())?'assistant-segment thinking-card-row':'assistant-segment'; row.innerHTML=_thinkingMarkup(text); - scrollToBottom(); + scrollIfPinned(); } function updateThinking(text=''){appendThinking(text);} function removeThinking(){ diff --git a/tests/test_issue357.py b/tests/test_issue357.py index db8ce42..450e77f 100644 --- a/tests/test_issue357.py +++ b/tests/test_issue357.py @@ -147,20 +147,21 @@ class TestWorkspacePermissions: ) def test_workspace_uses_sudo_chown(self): - """docker_init.bash must chown the workspace to hermeswebui after mkdir.""" - ws_section = INIT_SCRIPT[ - INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"): - INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800 - ] - assert "sudo chown" in ws_section and "hermeswebui" in ws_section, ( + """docker_init.bash must chown the workspace to hermeswebui when writable. + + The chown is now conditional on the workspace being writable, to allow + read-only (:ro) workspace mounts without crashing (#670). The sudo chown + must still be present in the script (just guarded by [ -w ]). + """ + assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"' in INIT_SCRIPT, ( "docker_init.bash must 'sudo chown hermeswebui:hermeswebui' the workspace " - "directory after creating it, so the app user can write to it (#357)" + "when it is writable, so the app user can write to it (#357)" ) def test_workspace_mkdir_before_chown(self): """sudo mkdir must come before sudo chown in docker_init.bash.""" - mkdir_pos = INIT_SCRIPT.find("sudo mkdir -p \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"") - chown_pos = INIT_SCRIPT.find("sudo chown hermeswebui:hermeswebui \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"") + mkdir_pos = INIT_SCRIPT.find('sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE"') + chown_pos = INIT_SCRIPT.find('sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"') assert mkdir_pos != -1, "sudo mkdir for workspace not found" assert chown_pos != -1, "sudo chown for workspace not found" assert mkdir_pos < chown_pos, "sudo mkdir must come before sudo chown" @@ -171,10 +172,19 @@ class TestWorkspacePermissions: "sudo mkdir for workspace must call error_exit on failure" ) - def test_workspace_error_exit_on_chown_failure(self): - """sudo chown must call error_exit on failure.""" - assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, ( - "sudo chown for workspace must call error_exit on failure" + def test_workspace_chown_is_conditional_on_writable(self): + """chown and write-test must be skipped for read-only workspace mounts (#670). + + The script must check [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ] before + attempting chown or a write test, so :ro bind-mounts don't crash startup. + """ + assert '[ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]' in INIT_SCRIPT, ( + "docker_init.bash must guard chown with [ -w ] to support read-only " + "workspace mounts (:ro) without crashing (#670)" + ) + # Read-only path must log a clear message rather than calling error_exit + assert "read-only workspace is supported" in INIT_SCRIPT, ( + "docker_init.bash must print a clear message when workspace is read-only (#670)" ) def test_init_script_syntax_valid(self): diff --git a/tests/test_issue569_579.py b/tests/test_issue569_579.py index d0e50b5..91d82b5 100644 --- a/tests/test_issue569_579.py +++ b/tests/test_issue569_579.py @@ -47,9 +47,9 @@ def test_569_autodetect_before_usermod(): def test_569_skips_root_uid(): """Auto-detect must not use UID 0 (root-owned mount = untrustworthy).""" - detect_block_start = INIT_SH.find("Auto-detect from mounted workspace") + detect_block_start = INIT_SH.find("Auto-detect from mounted volumes") assert detect_block_start != -1, "auto-detect comment block not found" - block = INIT_SH[detect_block_start:detect_block_start + 600] + block = INIT_SH[detect_block_start:detect_block_start + 1200] assert '"0"' in block or "'0'" in block, ( "Auto-detect block must skip UID 0 to avoid incorrectly using root ownership" ) @@ -65,6 +65,58 @@ def test_569_fallback_preserved(): ) +# ── #668: UID/GID auto-detect from hermes-home shared volume (two-container) ── + +def test_668_uid_autodetect_checks_hermes_home(): + """docker_init.bash must probe hermes-home dirs for UID in two-container setups. + + When hermes-agent and hermes-webui run in separate containers sharing a + named volume, /workspace may not exist but ~/.hermes will be owned by the + agent's UID. The init script must probe it so the webui user is remapped + to match (#668). + """ + assert "/home/hermeswebui/.hermes" in INIT_SH, ( + "docker_init.bash must probe /home/hermeswebui/.hermes for UID detection " + "to support two-container setups where /workspace may not exist (#668)" + ) + + +def test_668_gid_autodetect_checks_hermes_home(): + """docker_init.bash must probe hermes-home dirs for GID in two-container setups (#668).""" + # Both UID and GID detection share the same probe dirs — check GID block too + gid_detect_start = INIT_SH.find("Auto-detect GID from mounted volumes") + assert gid_detect_start != -1, ( + "GID auto-detect comment must be updated to mention shared volumes (#668)" + ) + gid_block = INIT_SH[gid_detect_start:gid_detect_start + 600] + assert "/home/hermeswebui/.hermes" in gid_block or "HERMES_HOME" in gid_block, ( + "GID auto-detect block must probe hermes-home dirs (#668)" + ) + + +def test_668_uid_probe_loop_uses_break(): + """UID probe loop must stop on first match (no double-detection).""" + uid_detect_start = INIT_SH.find("Auto-detect from mounted volumes") + assert uid_detect_start != -1, "UID auto-detect comment not found" + uid_block = INIT_SH[uid_detect_start:uid_detect_start + 1200] + assert "break" in uid_block, ( + "UID probe loop must break after first successful detection " + "to avoid being overridden by a later probe dir (#668)" + ) + + +def test_668_hermes_home_probe_before_workspace(): + """Hermes-home probe must appear before /workspace probe in docker_init.bash (#668).""" + hermes_home_pos = INIT_SH.find("/home/hermeswebui/.hermes") + workspace_pos = INIT_SH.find('if [ -d "/workspace" ]') + assert hermes_home_pos != -1, "/home/hermeswebui/.hermes probe not found" + assert workspace_pos != -1, "/workspace probe not found" + assert hermes_home_pos < workspace_pos, ( + "Hermes-home probe must come before /workspace probe — " + "shared volume UID should take priority over workspace UID (#668)" + ) + + # ── #579: topbar message count already filters tool messages ────────────────── def test_579_topbar_filters_tool_messages(): diff --git a/tests/test_issue677.py b/tests/test_issue677.py new file mode 100644 index 0000000..87cf044 --- /dev/null +++ b/tests/test_issue677.py @@ -0,0 +1,135 @@ +""" +Tests for fix #677: auto-scroll override during streaming. + +The scroll system has a _scrollPinned flag and scrollIfPinned() to respect +user scroll position. The bug was that scrollToBottom() was called +unconditionally inside renderMessages() and appendThinking(), even during +an active stream — overriding any scroll position the user had set. +""" +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8") +STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") + + +class TestScrollPinningFix: + + def test_render_messages_respects_active_stream(self): + """renderMessages() must not call scrollToBottom() while streaming (#677). + + During an active stream, scrollToBottom() unconditionally re-pins scroll + and overrides the user's position. renderMessages() must use scrollIfPinned() + instead when S.activeStreamId is set. + """ + # Find renderMessages function + rm_start = UI_JS.find("function renderMessages()") + assert rm_start != -1, "renderMessages() not found in ui.js" + rm_end = UI_JS.find("\nfunction ", rm_start + 1) + rm_body = UI_JS[rm_start:rm_end] + + # Must check activeStreamId before deciding which scroll fn to call + assert "activeStreamId" in rm_body, ( + "renderMessages() must check S.activeStreamId before scrolling — " + "unconditional scrollToBottom() overrides user scroll position (#677)" + ) + # scrollIfPinned must be called inside renderMessages (stream path) + assert "scrollIfPinned()" in rm_body, ( + "renderMessages() must call scrollIfPinned() during streaming (#677)" + ) + + def test_append_thinking_uses_scroll_if_pinned(self): + """appendThinking() must use scrollIfPinned() not scrollToBottom() (#677). + + appendThinking() fires continuously during streaming — calling scrollToBottom() + inside it re-pins on every token, preventing the user from scrolling up. + """ + at_start = UI_JS.find("function appendThinking(") + assert at_start != -1, "appendThinking() not found in ui.js" + at_end = UI_JS.find("\nfunction ", at_start + 1) + at_body = UI_JS[at_start:at_end] + + assert "scrollIfPinned()" in at_body, ( + "appendThinking() must call scrollIfPinned() not scrollToBottom() (#677)" + ) + assert "scrollToBottom()" not in at_body, ( + "appendThinking() must not call scrollToBottom() — it fires mid-stream (#677)" + ) + + def test_scroll_threshold_increased(self): + """Scroll re-pin threshold must be at least 150px (#677). + + 80px was too small — a fast mouse scroll wheel can jump 100–120px in one + tick, causing unintended re-pin. 150px gives a proper dead zone. + """ + # Find the nearBottom assignment in the scroll listener + near_bottom_pos = UI_JS.find("nearBottom=") + if near_bottom_pos == -1: + near_bottom_pos = UI_JS.find("nearBottom =") + assert near_bottom_pos != -1, "nearBottom scroll threshold assignment not found" + threshold_line = UI_JS[near_bottom_pos:near_bottom_pos + 120] + # Extract the numeric threshold + match = re.search(r"<\s*(\d+)", threshold_line) + assert match, f"Numeric threshold not found near nearBottom assignment: {threshold_line!r}" + threshold = int(match.group(1)) + assert threshold >= 150, ( + f"Scroll re-pin threshold is {threshold}px — must be >= 150px to avoid " + f"hair-trigger re-pinning on fast scroll wheels (#677)" + ) + + def test_scroll_to_bottom_button_exists_in_html(self): + """index.html must contain a scroll-to-bottom button (#677). + + All major streaming chat UIs (Claude, ChatGPT) show a floating ↓ button + when the user has scrolled up, giving a clear escape hatch to return to live output. + """ + assert "scrollToBottomBtn" in INDEX_HTML, ( + "index.html must contain a #scrollToBottomBtn element (#677)" + ) + assert "scroll-to-bottom-btn" in INDEX_HTML, ( + "index.html must use class scroll-to-bottom-btn for the scroll button (#677)" + ) + + def test_scroll_to_bottom_button_hidden_by_default(self): + """Scroll-to-bottom button must be hidden by default (display:none) (#677).""" + btn_pos = INDEX_HTML.find("scrollToBottomBtn") + assert btn_pos != -1 + btn_context = INDEX_HTML[btn_pos:btn_pos + 200] + assert "display:none" in btn_context or 'display="none"' in btn_context, ( + "scrollToBottomBtn must be hidden by default — only shown when user scrolls up (#677)" + ) + + def test_scroll_to_bottom_button_css_exists(self): + """style.css must have styling for .scroll-to-bottom-btn (#677).""" + assert ".scroll-to-bottom-btn" in STYLE_CSS, ( + "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).""" + 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)" + ) + + def test_scroll_listener_hides_button_when_pinned(self): + """Scroll listener must hide the button when user is near the bottom (#677).""" + scroll_listener_start = UI_JS.find("el.addEventListener('scroll'") + assert scroll_listener_start != -1, "scroll event listener not found" + listener_block = UI_JS[scroll_listener_start:scroll_listener_start + 300] + assert "scrollToBottomBtn" in listener_block, ( + "Scroll listener must show/hide scrollToBottomBtn based on _scrollPinned (#677)" + ) + + def test_scroll_to_bottom_button_calls_scroll_to_bottom(self): + """scrollToBottomBtn onclick must call scrollToBottom() (#677).""" + btn_pos = INDEX_HTML.find("scrollToBottomBtn") + assert btn_pos != -1 + btn_context = INDEX_HTML[btn_pos:btn_pos + 200] + assert "scrollToBottom()" in btn_context, ( + "scrollToBottomBtn onclick must call scrollToBottom() (#677)" + ) diff --git a/tests/test_issues_373_374_375.py b/tests/test_issues_373_374_375.py index f3dbc2f..93d7172 100644 --- a/tests/test_issues_373_374_375.py +++ b/tests/test_issues_373_374_375.py @@ -230,3 +230,88 @@ class TestLiveModelFetching: assert live_route_pos > handle_get_pos, ( "/api/models/live must be inside handle_get() (#375)" ) + + +# ── #669: Gemini model IDs must be valid for Google AI Studio endpoint ──────── + +class TestGeminiModelIds: + """Gemini 3.x model IDs must be valid for the native Google AI Studio provider. + + The original code had gemini-3.1-flash-lite-preview missing from the + dropdown. The fallback list also erroneously used gemini-3.1-pro-preview + in some provider sections while omitting gemini-3.1-flash-lite-preview. + All provider sections must now include the full current Gemini 3.x lineup. + """ + + VALID_GEMINI_3 = [ + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + "gemini-3.1-flash-lite-preview", + ] + + def test_gemini_provider_models_has_3x(self): + """_PROVIDER_MODELS['gemini'] must contain valid Gemini 3.x model IDs (#669).""" + gemini_block_start = CONFIG_PY.find('"gemini": [') + assert gemini_block_start != -1, "_PROVIDER_MODELS['gemini'] block not found" + gemini_block = CONFIG_PY[gemini_block_start:gemini_block_start + 600] + for mid in self.VALID_GEMINI_3: + assert mid in gemini_block, ( + f"_PROVIDER_MODELS['gemini'] must contain {mid!r} — " + f"this is a valid Google AI Studio model ID (#669)" + ) + + def test_gemini_provider_models_has_flash_lite(self): + """_PROVIDER_MODELS['gemini'] must contain gemini-3.1-flash-lite-preview (#669). + + This was the model the reporter selected from the wizard — it must appear + in the native gemini provider model list so users can select it. + """ + gemini_block_start = CONFIG_PY.find('"gemini": [') + assert gemini_block_start != -1 + gemini_block = CONFIG_PY[gemini_block_start:gemini_block_start + 600] + assert "gemini-3.1-flash-lite-preview" in gemini_block, ( + "_PROVIDER_MODELS['gemini'] missing gemini-3.1-flash-lite-preview — " + "this was the exact model the #669 reporter tried and got API_KEY_INVALID" + ) + + def test_fallback_models_has_gemini_3x(self): + """_FALLBACK_MODELS must contain valid Gemini 3.x OpenRouter model IDs (#669).""" + fallback_start = CONFIG_PY.find("_FALLBACK_MODELS = [") + fallback_end = CONFIG_PY.find("]", fallback_start + len("_FALLBACK_MODELS = [")) + # Find the closing bracket for the list (multi-line) + depth = 0 + pos = fallback_start + len("_FALLBACK_MODELS = [") + for i, ch in enumerate(CONFIG_PY[pos:], start=pos): + if ch == '[': + depth += 1 + elif ch == ']': + if depth == 0: + fallback_end = i + break + depth -= 1 + fallback_block = CONFIG_PY[fallback_start:fallback_end] + for mid in ("google/gemini-3.1-pro-preview", "google/gemini-3-flash-preview"): + assert mid in fallback_block, ( + f"_FALLBACK_MODELS must contain {mid!r} for OpenRouter Google models (#669)" + ) + + def test_gemini_provider_also_has_stable_25(self): + """_PROVIDER_MODELS['gemini'] must retain stable Gemini 2.5 models (#669).""" + gemini_block_start = CONFIG_PY.find('"gemini": [') + assert gemini_block_start != -1 + gemini_block = CONFIG_PY[gemini_block_start:gemini_block_start + 600] + assert "gemini-2.5-pro" in gemini_block, ( + "_PROVIDER_MODELS['gemini'] must keep gemini-2.5-pro as a stable fallback" + ) + + def test_no_invalid_gemini_3_pro_model(self): + """gemini-3-pro-preview must not appear — it was shut down March 9 2026 (#669).""" + assert "gemini-3-pro-preview" not in CONFIG_PY or "gemini-3.1-pro-preview" in CONFIG_PY, ( + "gemini-3-pro-preview was shut down — use gemini-3.1-pro-preview instead (#669)" + ) + # More precise: ensure the bare (non-.1) version isn't the only one present + count_bare = CONFIG_PY.count('"gemini-3-pro-preview"') + assert count_bare == 0, ( + f"gemini-3-pro-preview appears {count_bare} time(s) in config.py — " + "it was shut down March 9 2026, use gemini-3.1-pro-preview (#669)" + )