v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS catalog so they appear in the model picker for the openai, openai-codex, and copilot providers. Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent * fix(models): add gpt-5.5-mini to copilot provider catalog * fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044) Mermaid's built-in 'dark' and 'default' themes inject an @import for fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src only allows cdn.jsdelivr.net, so this request is blocked on every diagram render, filling the console with CSP errors. Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables block of mermaid.initialize() in renderMermaidBlocks(). This suppresses Mermaid's external font import and uses the page's existing font stack. Avoids adding fonts.googleapis.com to the CSP — no new external dependency, no font FOUT, consistent with the rest of the UI typography. 3 regression tests added in tests/test_1044_mermaid_csp_font.py. 2215/2215 tests passing. * fix(onboarding): non-standard provider/path cluster (#1029) * fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045) The pageshow handler added for #822 only cleared the session search filter and re-rendered the session list. This left the rest of the layout chrome (topbar, rail icons, workspace panel, resize handles, gateway SSE) in the stale bfcache DOM state, causing a broken layout (oversized search icon, uninitialized rail) that required a hard refresh to fix. Fix: extend the pageshow handler to re-run the full set of layout sync calls that the boot IIFE runs on a fresh page load: syncTopbar() — restores model chip, title, topbar state syncWorkspacePanelState() — restores workspace panel open/closed _initResizePanels() — reattaches panel resize drag listeners startGatewaySSE() — reconnects the gateway SSE watcher (bfcache-persisted connections are dead) All four calls are typeof-guarded for safe degradation if a helper is not yet defined. The existing #822 fixes (sessionSearch clear + renderSessionListFromCache) are preserved unchanged. loadSession() is intentionally NOT re-called — it would cause message flicker; the sync calls above are sufficient to restore visual state. 7 regression tests added in tests/test_1045_bfcache_layout_restore.py. 2219/2219 tests passing. * fix(bfcache): also close open dropdowns on bfcache restore (#1045) Additional symptom noted in issue #1045: bfcache freezes the DOM including any open dropdown/popover state. The thinking-level selector (and other composer dropdowns) left open when navigating away would appear open without user interaction on tab restore. Extend the pageshow handler to call all four named close functions before the layout sync: closeModelDropdown() — composer model selector closeReasoningDropdown() — thinking/reasoning effort selector closeWsDropdown() — workspace chip dropdown closeProfileDropdown() — profile switcher dropdown All calls are typeof-guarded, matching the style of the layout sync calls already in the handler. 2 new tests (9 total in test_1045_bfcache_layout_restore.py): - pageshow closes all four named dropdowns - dropdown closes appear before layout sync calls (clean state first) 2221/2221 tests passing. * fix(bfcache): remove _initResizePanels() — bfcache preserves listeners * fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test * fix(sessions): use cron job name as session title when available (#1032) * fix(test): add id column to messages table in cron title test fixture * fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block * fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038) When an auth session expires, the server returns a 302→/login for page requests. In a normal browser this works fine, but in an iOS PWA running in standalone mode the redirect navigates out of the PWA shell into Safari, leaving the app permanently stuck on 'Authentication required' with no recovery path. Fix: intercept 401 responses client-side before surfacing any error. - workspace.js api(): check res.status===401 first; call window.location.href='/login' and return immediately (no throw) - ui.js: add _redirectIfUnauth() helper; wire into all direct fetch() calls that bypass api() — api/models, api/models/live, api/upload All fetch paths that could receive a 401 now redirect cleanly within the PWA frame rather than opening Safari. 6 regression tests added in tests/test_1038_pwa_auth_redirect.py. 2175/2175 tests passing. * fix(pwa): preserve current URL in ?next= param on 401 redirect * fix(test): update 401-redirect assertion to accept ?next= URL format * feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by the login success handler (always redirected to ./). _safeNextPath() validates and returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs, // protocol-relative URLs, backslash variants, and control characters. 4 new regression tests added. * Implement session agent cache for AIAgent reuse Added session agent cache to reuse AIAgent across messages. * Implement agent caching for session management * Implement session agent eviction on session deletion Added session agent eviction to prevent turn count leakage in recycled sessions. * docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27) * docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210 Three entries in the [Unreleased] section are duplicates of items now listed under v0.50.210: - Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP - bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore - iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL The original drafts landed in [Unreleased] when individual PRs (#1047, #1048, #1043) were approved; the v0.50.210 release-notes commit then added the same items under the version section without removing the [Unreleased] copies. Drop the duplicates so users reading the CHANGELOG don't see the same fix listed twice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: qxxaa <mrhanoi@outlook.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7d1aa2e261
commit
6c343aff84
17 changed files with 649 additions and 23 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -2,7 +2,18 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## v0.50.210 — 2026-04-25
|
||||
|
||||
### Added
|
||||
- **gpt-5.5 and gpt-5.5-mini in model picker** — available for openai, openai-codex, and copilot providers. (`api/config.py`) [#1052 @aliceisjustplaying]
|
||||
- **Login redirects back to original URL after re-login** — the iOS PWA auth redirect now passes `?next=` with the current path; `login.js` honors it via a `_safeNextPath()` helper that guards against open-redirect (rejects `//`, backslash, and non-path-absolute inputs). (`static/login.js`, `static/ui.js`, `static/workspace.js`) [#1053]
|
||||
|
||||
### Fixed
|
||||
- **Non-standard provider first-run experience** — agent dir discovery now searches XDG_DATA_HOME, `/opt`, `/usr/local` paths; onboarding wizard auto-completes for non-wizard providers (ollama-cloud, deepseek, xai, kimi-k2.6) with `provider_configured=True`; wizard model field no longer hardcodes `gpt-5.4-mini` literal; session model resolver correctly handles unlisted active providers. (`api/config.py`, `api/onboarding.py`, `api/routes.py`) Closes #1019–#1023 [#1049]
|
||||
- **Cron session titles in sidebar** — cron-launched sessions now display the human-friendly job name (from `~/.hermes/cron/jobs.json`) instead of a generic "Cron Session" label. (`api/models.py`, `api/routes.py`) [#1050 @waldmanz]
|
||||
- **AIAgent reused per session — fixes Honcho first-turn injection** — `AIAgent` is now cached per `session_id` so the agent's turn counter increments correctly across messages. Cache is evicted on session delete/clear. (`api/config.py`, `api/routes.py`, `api/streaming.py`) Closes #1039 [#1051 @qxxaa]
|
||||
- **Mermaid Google Fonts CSP violation suppressed** — `fontFamily:'inherit'` in Mermaid themeVariables prevents `@import url('fonts.googleapis.com')` from being injected into diagram SVGs. (`static/ui.js`) Closes #1044 [#1054]
|
||||
- **bfcache layout and dropdown restore** — `pageshow+event.persisted` handler re-syncs topbar, workspace panel, session list, and gateway SSE; also closes open composer dropdowns frozen by bfcache. `_initResizePanels()` removed from pageshow (bfcache preserves listeners). (`static/boot.js`) Closes #1045 [#1055]
|
||||
|
||||
## v0.50.209 — 2026-04-25
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated coverage: 2212 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Automated coverage: 2239 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
>
|
||||
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
|
||||
|
|
|
|||
|
|
@ -91,6 +91,14 @@ def _discover_agent_dir() -> Path:
|
|||
# 6. ~/hermes-agent
|
||||
candidates.append(HOME / "hermes-agent")
|
||||
|
||||
# 7. XDG_DATA_HOME / hermes-agent (e.g. ~/.local/share/hermes-agent)
|
||||
xdg_data = Path(os.getenv("XDG_DATA_HOME", str(HOME / ".local" / "share")))
|
||||
candidates.append(xdg_data.expanduser() / "hermes-agent")
|
||||
|
||||
# 8. System-wide install paths (e.g. /opt/hermes-agent, /usr/local/hermes-agent)
|
||||
for sys_prefix in ("/opt", "/usr/local", "/usr/local/share"):
|
||||
candidates.append(Path(sys_prefix) / "hermes-agent")
|
||||
|
||||
for path in candidates:
|
||||
if path.exists() and (path / "run_agent.py").exists():
|
||||
return path.resolve()
|
||||
|
|
@ -597,10 +605,14 @@ _PROVIDER_MODELS = {
|
|||
{"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
||||
],
|
||||
"openai": [
|
||||
{"id": "gpt-5.5", "label": "GPT-5.5"},
|
||||
{"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"},
|
||||
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
|
||||
{"id": "gpt-5.4", "label": "GPT-5.4"},
|
||||
],
|
||||
"openai-codex": [
|
||||
{"id": "gpt-5.5", "label": "GPT-5.5"},
|
||||
{"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"},
|
||||
{"id": "gpt-5.4", "label": "GPT-5.4"},
|
||||
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
|
||||
{"id": "gpt-5.3-codex", "label": "GPT-5.3 Codex"},
|
||||
|
|
@ -650,6 +662,8 @@ _PROVIDER_MODELS = {
|
|||
],
|
||||
# GitHub Copilot — model IDs served via the Copilot API
|
||||
"copilot": [
|
||||
{"id": "gpt-5.5", "label": "GPT-5.5"},
|
||||
{"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"},
|
||||
{"id": "gpt-5.4", "label": "GPT-5.4"},
|
||||
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
|
||||
{"id": "gpt-4o", "label": "GPT-4o"},
|
||||
|
|
@ -1704,6 +1718,18 @@ AGENT_INSTANCES: dict = {} # stream_id -> AIAgent instance for interrupt propag
|
|||
STREAM_PARTIAL_TEXT: dict = {} # stream_id -> partial assistant text accumulated during streaming
|
||||
SERVER_START_TIME = time.time()
|
||||
|
||||
# Agent cache: reuse AIAgent across messages in the same WebUI session so that
|
||||
# _user_turn_count survives between turns. This mirrors the gateway's
|
||||
# _agent_cache pattern and is required for injectionFrequency: "first-turn".
|
||||
SESSION_AGENT_CACHE: dict = {} # session_id -> (AIAgent, config_sig)
|
||||
SESSION_AGENT_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _evict_session_agent(session_id: str) -> None:
|
||||
"""Remove a cached agent for a session (on delete, clear, or model switch)."""
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
SESSION_AGENT_CACHE.pop(session_id, None)
|
||||
|
||||
# ── Thread-local env context ─────────────────────────────────────────────────
|
||||
_thread_ctx = threading.local()
|
||||
|
||||
|
|
|
|||
|
|
@ -691,7 +691,25 @@ def get_cli_sessions() -> list:
|
|||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
|
||||
_source = row['source'] or 'cli'
|
||||
_display_title = row['title'] or f'{_source.title()} Session'
|
||||
_title = row['title']
|
||||
if not _title and _source == 'cron' and sid.startswith('cron_'):
|
||||
# Extract job_id from session ID (cron_{job_id}_{timestamp})
|
||||
# and look up the human-friendly job name from jobs.json
|
||||
parts = sid.split('_')
|
||||
if len(parts) >= 3:
|
||||
_job_id = parts[1]
|
||||
try:
|
||||
_jobs_path = hermes_home / 'cron' / 'jobs.json'
|
||||
if _jobs_path.exists():
|
||||
import json as _json
|
||||
_jobs_data = _json.loads(_jobs_path.read_text())
|
||||
for _j in _jobs_data.get('jobs', []):
|
||||
if _j.get('id') == _job_id:
|
||||
_title = _j.get('name') or _title
|
||||
break
|
||||
except Exception:
|
||||
pass # degrade gracefully
|
||||
_display_title = _title or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _display_title,
|
||||
|
|
|
|||
|
|
@ -518,11 +518,33 @@ def get_onboarding_status() -> dict:
|
|||
auto_completed = skip_requested # unconditional: operator says skip, we skip
|
||||
|
||||
# Auto-complete for existing Hermes users: if config.yaml already exists
|
||||
# AND the system is chat_ready, treat onboarding as done. These users
|
||||
# configured Hermes via the CLI before the Web UI existed; they must never
|
||||
# be shown the first-run wizard — it would silently overwrite their config.
|
||||
# AND the provider is configured (or the system is chat_ready), treat onboarding
|
||||
# as done. These users configured Hermes via the CLI before the Web UI existed;
|
||||
# they must never be shown the first-run wizard — it would silently overwrite their
|
||||
# config. We use provider_configured (not chat_ready) so that users with
|
||||
# non-wizard providers (ollama-cloud, deepseek, xai, kimi, etc.) are not forced
|
||||
# through the wizard just because their provider doesn't have a detectable API key
|
||||
# — the wizard cannot represent their provider and would overwrite their config
|
||||
# with whichever wizard-supported provider they accidentally select.
|
||||
config_exists = Path(_get_config_path()).exists()
|
||||
config_auto_completed = config_exists and bool(runtime.get("chat_ready"))
|
||||
|
||||
# For providers not in the wizard's quick-setup list (e.g. ollama-cloud, deepseek,
|
||||
# xai, kimi-k2.6), the wizard can never help — it only knows how to configure
|
||||
# openrouter/anthropic/openai/google/custom. If such a user has a configured
|
||||
# provider + model in config.yaml, showing the wizard would only confuse them
|
||||
# (or worse, let them accidentally overwrite their config with gpt-5.4-mini).
|
||||
_current_provider = str(
|
||||
(cfg.get("model", {}) or {}).get("provider", "") if isinstance(cfg.get("model"), dict)
|
||||
else ""
|
||||
).strip().lower()
|
||||
_is_non_wizard_provider = bool(
|
||||
_current_provider and _current_provider not in _SUPPORTED_PROVIDER_SETUPS
|
||||
)
|
||||
|
||||
config_auto_completed = config_exists and (
|
||||
bool(runtime.get("chat_ready"))
|
||||
or (_is_non_wizard_provider and bool(runtime.get("provider_configured")))
|
||||
)
|
||||
|
||||
# Persist the flag so it survives future transient import failures (e.g. after
|
||||
# a git branch switch in the hermes-agent repo). Without this, a CLI-configured
|
||||
|
|
|
|||
|
|
@ -232,7 +232,13 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]:
|
|||
return default_model, bool(default_model)
|
||||
|
||||
active_provider = _normalize_provider_id(catalog.get("active_provider"))
|
||||
if not active_provider:
|
||||
# Also keep the raw active_provider slug for cross-provider detection with
|
||||
# non-listed providers (ollama-cloud, deepseek, xai, etc.) that _normalize_provider_id
|
||||
# returns "" for. If the raw provider is set but normalization returned "", we still
|
||||
# want to detect that a session model from a known provider (e.g. openai/gpt-5.4-mini)
|
||||
# is stale relative to this unknown active provider. (#1023)
|
||||
raw_active_provider = str(catalog.get("active_provider") or "").strip().lower()
|
||||
if not active_provider and not raw_active_provider:
|
||||
return model, False
|
||||
|
||||
slash = model.find("/")
|
||||
|
|
@ -275,7 +281,12 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]:
|
|||
|
||||
# Skip normalization for models on custom/openrouter namespaces — these are
|
||||
# user-controlled and should never be silently replaced.
|
||||
if model_provider and model_provider not in {"", "custom", "openrouter"} and model_provider != active_provider and default_model:
|
||||
# Also normalize when the model is from a known provider but the active provider
|
||||
# is an unlisted one (e.g. ollama-cloud) — active_provider is "" in that case
|
||||
# but raw_active_provider is set. If model_provider doesn't start with the raw
|
||||
# active provider name, the session model is stale. (#1023)
|
||||
_active_for_compare = active_provider or raw_active_provider
|
||||
if model_provider and model_provider not in {"", "custom", "openrouter"} and model_provider != _active_for_compare and default_model:
|
||||
return default_model, True
|
||||
return model, False
|
||||
|
||||
|
|
@ -1224,6 +1235,9 @@ def handle_post(handler, parsed) -> bool:
|
|||
# Delete from WebUI session store
|
||||
with LOCK:
|
||||
SESSIONS.pop(sid, None)
|
||||
# Evict cached agent so turn count doesn't leak into a recycled session
|
||||
from api.config import _evict_session_agent
|
||||
_evict_session_agent(sid)
|
||||
try:
|
||||
p = (SESSION_DIR / f"{sid}.json").resolve()
|
||||
p.relative_to(SESSION_DIR.resolve())
|
||||
|
|
@ -1264,6 +1278,9 @@ def handle_post(handler, parsed) -> bool:
|
|||
s.tool_calls = []
|
||||
s.title = "Untitled"
|
||||
s.save()
|
||||
# Evict cached agent — cleared session is a fresh conversation
|
||||
from api.config import _evict_session_agent
|
||||
_evict_session_agent(body["session_id"])
|
||||
return j(handler, {"ok": True, "session": s.compact()})
|
||||
|
||||
if parsed.path == "/api/session/truncate":
|
||||
|
|
@ -3552,22 +3569,23 @@ def _handle_session_import_cli(handler, body):
|
|||
if not msgs:
|
||||
return bad(handler, "Session not found in CLI store", 404)
|
||||
|
||||
# Derive title from first user message
|
||||
title = title_from(msgs, "CLI Session")
|
||||
model = "unknown"
|
||||
|
||||
# Get profile, model, and timestamps from CLI session metadata
|
||||
# Get profile, model, timestamps, and title from CLI session metadata
|
||||
profile = None
|
||||
created_at = None
|
||||
updated_at = None
|
||||
cli_title = None
|
||||
for cs in get_cli_sessions():
|
||||
if cs["session_id"] == sid:
|
||||
profile = cs.get("profile")
|
||||
model = cs.get("model", "unknown")
|
||||
created_at = cs.get("created_at")
|
||||
updated_at = cs.get("updated_at")
|
||||
cli_title = cs.get("title")
|
||||
break
|
||||
|
||||
# Use the CLI session title if available (e.g., cron job name), otherwise derive from messages
|
||||
title = cli_title or title_from(msgs, "CLI Session")
|
||||
|
||||
s = import_cli_session(
|
||||
sid,
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -1398,7 +1398,56 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||
if 'gateway_session_key' in _agent_params:
|
||||
_agent_kwargs['gateway_session_key'] = session_id
|
||||
|
||||
agent = _AIAgent(**_agent_kwargs)
|
||||
# ── Agent cache: reuse across messages in the same session ──
|
||||
# Mirrors gateway _agent_cache. Keeps _user_turn_count alive so
|
||||
# injectionFrequency: "first-turn" actually suppresses after turn 1.
|
||||
if ephemeral:
|
||||
agent = _AIAgent(**_agent_kwargs)
|
||||
logger.debug('[webui] Created ephemeral agent for session %s', session_id)
|
||||
else:
|
||||
import hashlib as _hashlib
|
||||
import json as _json
|
||||
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK
|
||||
_sig_blob = _json.dumps([
|
||||
resolved_model or '',
|
||||
_hashlib.sha256((resolved_api_key or '').encode()).hexdigest()[:16],
|
||||
resolved_base_url or '',
|
||||
resolved_provider or '',
|
||||
sorted(_toolsets) if _toolsets else [],
|
||||
], sort_keys=True)
|
||||
_agent_sig = _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16]
|
||||
|
||||
agent = None
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
_cached = SESSION_AGENT_CACHE.get(session_id)
|
||||
if _cached and _cached[1] == _agent_sig:
|
||||
agent = _cached[0]
|
||||
logger.debug('[webui] Reusing cached agent for session %s', session_id)
|
||||
|
||||
if agent is not None:
|
||||
# Refresh per-turn callbacks — these close over request-scoped
|
||||
# objects (put queue, cancel_event) that are new each request.
|
||||
agent.stream_delta_callback = _agent_kwargs.get('stream_delta_callback')
|
||||
agent.tool_progress_callback = _agent_kwargs.get('tool_progress_callback')
|
||||
if hasattr(agent, 'reasoning_callback'):
|
||||
agent.reasoning_callback = _agent_kwargs.get('reasoning_callback')
|
||||
if hasattr(agent, 'clarify_callback'):
|
||||
agent.clarify_callback = _agent_kwargs.get('clarify_callback')
|
||||
if _session_db is not None:
|
||||
agent._session_db = _session_db
|
||||
if hasattr(agent, '_api_call_count'):
|
||||
agent._api_call_count = 0
|
||||
# Reset interrupt state from a prior cancel so the reused
|
||||
# agent does not think it is still interrupted.
|
||||
if hasattr(agent, '_interrupted'):
|
||||
agent._interrupted = False
|
||||
if hasattr(agent, '_interrupt_message'):
|
||||
agent._interrupt_message = None
|
||||
else:
|
||||
agent = _AIAgent(**_agent_kwargs)
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)
|
||||
logger.debug('[webui] Created new agent for session %s', session_id)
|
||||
|
||||
# Store agent instance for cancel/interrupt propagation
|
||||
with STREAMS_LOCK:
|
||||
|
|
@ -1648,6 +1697,13 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||
with SESSION_AGENT_LOCKS_LOCK:
|
||||
SESSION_AGENT_LOCKS[new_sid] = _agent_lock
|
||||
SESSION_AGENT_LOCKS.pop(old_sid, None)
|
||||
# Migrate cached agent to the new session ID so the turn
|
||||
# count survives context compression.
|
||||
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
_cached_entry = SESSION_AGENT_CACHE.pop(old_sid, None)
|
||||
if _cached_entry:
|
||||
SESSION_AGENT_CACHE[new_sid] = _cached_entry
|
||||
if old_path.exists() and not new_path.exists():
|
||||
try:
|
||||
old_path.rename(new_path)
|
||||
|
|
|
|||
|
|
@ -898,13 +898,27 @@ function applyBotName(){
|
|||
// back-forward cache, the async boot IIFE above does NOT re-run, but the
|
||||
// DOM — including any stale value in #sessionSearch — IS restored. A
|
||||
// prior search string would silently hide all sessions via the filter in
|
||||
// renderSessionListFromCache(). Clear the field and re-render whenever
|
||||
// the page is restored from cache (`event.persisted === true`).
|
||||
// renderSessionListFromCache(). Clear the field and re-run the full layout
|
||||
// sync whenever the page is restored from cache (`event.persisted === true`).
|
||||
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
|
||||
// chrome aren't left in the stale bfcache snapshot.
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (!event.persisted) return; // fresh loads are handled by the IIFE above
|
||||
const _srch = document.getElementById('sessionSearch');
|
||||
if (_srch) _srch.value = '';
|
||||
// Close any dropdowns/popovers that were open when the user navigated away.
|
||||
// bfcache freezes DOM state, so a dropdown left open remains open on restore.
|
||||
if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {}
|
||||
if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
|
||||
if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
|
||||
if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
|
||||
// Re-synchronise layout chrome that the boot IIFE sets up but bfcache
|
||||
// doesn't re-run. Each call is guarded so missing helpers degrade silently.
|
||||
if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}
|
||||
if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {}
|
||||
if (typeof renderSessionListFromCache === 'function') {
|
||||
try { renderSessionListFromCache(); } catch (_) {}
|
||||
}
|
||||
// Restart the gateway SSE watcher — the persisted connection is dead after bfcache
|
||||
if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,20 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
if (err) { err.style.display = 'none'; }
|
||||
}
|
||||
|
||||
// Return the ?next= redirect path if present and safe, otherwise './'
|
||||
// Guards against open-redirect: rejects protocol-relative (//evil.com),
|
||||
// absolute URLs, backslash variants, and control characters.
|
||||
function _safeNextPath() {
|
||||
try {
|
||||
var raw = new URL(window.location.href).searchParams.get('next');
|
||||
if (!raw) return './';
|
||||
if (raw.charAt(0) !== '/') return './'; // must be path-absolute
|
||||
if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\
|
||||
if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace
|
||||
return raw;
|
||||
} catch (_) { return './'; }
|
||||
}
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
var pw = input.value;
|
||||
|
|
@ -35,7 +49,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
var data = {};
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (res.ok && data.ok) {
|
||||
window.location.href = './';
|
||||
window.location.href = _safeNextPath();
|
||||
} else {
|
||||
showErr(data.error || invalidPw);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ async function loadOnboardingWizard(){
|
|||
const current=((status.setup||{}).current)||{};
|
||||
ONBOARDING.form.provider=current.provider||'openrouter';
|
||||
ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||'';
|
||||
ONBOARDING.form.model=status.settings.default_model||current.model||'openai/gpt-5.4-mini';
|
||||
ONBOARDING.form.model=status.settings.default_model||current.model||'';
|
||||
ONBOARDING.form.password='';
|
||||
ONBOARDING.form.apiKey='';
|
||||
ONBOARDING.form.baseUrl=current.base_url||'';
|
||||
|
|
|
|||
14
static/ui.js
14
static/ui.js
|
|
@ -9,6 +9,10 @@ const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
|
|||
// single-threaded so only one done event fires at a time in practice.
|
||||
let _queueDrainSid=null;
|
||||
const $=id=>document.getElementById(id);
|
||||
// Redirect to /login when the server responds with 401 (auth session expired).
|
||||
// Handles iOS PWA standalone mode where a server-side 302→/login would break
|
||||
// out of the PWA shell into Safari instead of navigating within it.
|
||||
function _redirectIfUnauth(res){if(res&&res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return true;}return false;}
|
||||
function _getSessionQueue(sid, create=false){
|
||||
if(!sid) return [];
|
||||
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
|
||||
|
|
@ -87,7 +91,9 @@ async function populateModelDropdown(){
|
|||
const sel=$('modelSelect');
|
||||
if(!sel) return;
|
||||
try{
|
||||
const data=await fetch(new URL('api/models',location.href).href,{credentials:'include'}).then(r=>r.json());
|
||||
const _modelsRes=await fetch(new URL('api/models',location.href).href,{credentials:'include'});
|
||||
if(_redirectIfUnauth(_modelsRes)) return;
|
||||
const data=await _modelsRes.json();
|
||||
if(!data.groups||!data.groups.length) return; // keep HTML defaults
|
||||
// Store active provider globally so the send path can warn on mismatch
|
||||
window._activeProvider=data.active_provider||null;
|
||||
|
|
@ -191,7 +197,9 @@ async function _fetchLiveModels(provider, sel){
|
|||
try{
|
||||
const url=new URL('api/models/live',location.href);
|
||||
url.searchParams.set('provider',provider);
|
||||
const data=await fetch(url.href,{credentials:'include'}).then(r=>r.json());
|
||||
const _liveRes=await fetch(url.href,{credentials:'include'});
|
||||
if(_redirectIfUnauth(_liveRes)) return;
|
||||
const data=await _liveRes.json();
|
||||
if(!data.models||!data.models.length) return;
|
||||
_liveModelCache[provider]=data.models;
|
||||
const added=_addLiveModelsToSelect(provider,data.models,sel);
|
||||
|
|
@ -2682,6 +2690,7 @@ function renderMermaidBlocks(){
|
|||
script.onload=()=>{
|
||||
if(typeof mermaid!=='undefined'){
|
||||
mermaid.initialize({startOnLoad:false,theme:document.documentElement.classList.contains('dark')?'dark':'default',themeVariables:{
|
||||
fontFamily:'inherit',fontSize:'14px',
|
||||
primaryColor:'#4a6fa5',primaryTextColor:'#e2e8f0',lineColor:'#718096',
|
||||
secondaryColor:'#2d3748',tertiaryColor:'#1a202c',primaryBorderColor:'#4a5568',
|
||||
}});
|
||||
|
|
@ -3125,6 +3134,7 @@ async function uploadPendingFiles(){
|
|||
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
|
||||
try{
|
||||
const res=await fetch(new URL('api/upload',location.href).href,{method:'POST',credentials:'include',body:fd});
|
||||
if(_redirectIfUnauth(res)) return;
|
||||
if(!res.ok){const err=await res.text();throw new Error(err);}
|
||||
const data=await res.json();
|
||||
if(data.error)throw new Error(data.error);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ async function api(path,opts={}){
|
|||
const url=new URL(rel,location.href);
|
||||
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
|
||||
if(!res.ok){
|
||||
// 401 means the auth session expired. Redirect to /login so the user can
|
||||
// re-authenticate. This is especially important for iOS PWA (standalone mode)
|
||||
// where a server-side 302 → /login opens in Safari instead of within the PWA.
|
||||
if(res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;}
|
||||
const text=await res.text();
|
||||
// Parse JSON error body and surface the human-readable message,
|
||||
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
|
||||
|
|
|
|||
106
tests/test_1038_pwa_auth_redirect.py
Normal file
106
tests/test_1038_pwa_auth_redirect.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""
|
||||
Tests for issue #1038 — iOS PWA auth-expiry redirect.
|
||||
|
||||
When a 401 is returned by any API endpoint, the client-side JS should redirect
|
||||
to /login rather than showing a raw error toast. On iOS PWA standalone mode a
|
||||
server-side 302→/login breaks out of the PWA shell into Safari, so the fix is
|
||||
client-side: workspace.js api() intercepts 401 before throwing and calls
|
||||
window.location.href = '/login'.
|
||||
|
||||
These are static regression tests that verify the JS source contains the
|
||||
correct guard patterns.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _workspace_js() -> str:
|
||||
return (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _ui_js() -> str:
|
||||
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestPWAAuthRedirect:
|
||||
def test_workspace_js_has_401_redirect(self):
|
||||
"""api() in workspace.js must redirect to /login on 401."""
|
||||
src = _workspace_js()
|
||||
# Guard must appear inside the !res.ok block, before throwing
|
||||
assert "res.status===401" in src, \
|
||||
"workspace.js api() must check res.status===401"
|
||||
assert "window.location.href='/login" in src or 'window.location.href="/login' in src, \
|
||||
"workspace.js api() must redirect to /login on 401"
|
||||
|
||||
def test_workspace_js_401_before_throw(self):
|
||||
"""The 401 redirect must come before the generic error throw."""
|
||||
src = _workspace_js()
|
||||
idx_401 = src.find("res.status===401")
|
||||
idx_throw = src.find("throw new Error")
|
||||
assert idx_401 != -1, "401 guard not found in workspace.js"
|
||||
assert idx_throw != -1, "throw not found in workspace.js"
|
||||
assert idx_401 < idx_throw, \
|
||||
"401 redirect must appear before the generic throw in workspace.js"
|
||||
|
||||
def test_ui_js_has_redirect_helper(self):
|
||||
"""ui.js must define _redirectIfUnauth helper."""
|
||||
src = _ui_js()
|
||||
assert "_redirectIfUnauth" in src, \
|
||||
"ui.js must define _redirectIfUnauth helper function"
|
||||
|
||||
def test_ui_js_models_fetch_uses_redirect(self):
|
||||
"""populateModelDropdown() must call _redirectIfUnauth on the api/models response."""
|
||||
src = _ui_js()
|
||||
# The helper must be called after the api/models fetch
|
||||
assert "_redirectIfUnauth(_modelsRes)" in src, \
|
||||
"populateModelDropdown() must check 401 on api/models fetch"
|
||||
|
||||
def test_ui_js_live_models_fetch_uses_redirect(self):
|
||||
"""loadLiveModels() must call _redirectIfUnauth on the api/models/live response."""
|
||||
src = _ui_js()
|
||||
assert "_redirectIfUnauth(_liveRes)" in src, \
|
||||
"loadLiveModels() must check 401 on api/models/live fetch"
|
||||
|
||||
def test_ui_js_upload_fetch_uses_redirect(self):
|
||||
"""File upload must call _redirectIfUnauth on the api/upload response."""
|
||||
src = _ui_js()
|
||||
assert "_redirectIfUnauth(res)" in src, \
|
||||
"upload fetch must call _redirectIfUnauth"
|
||||
|
||||
|
||||
class TestLoginJsSafeNextPath:
|
||||
"""login.js _safeNextPath() must honor ?next= but reject open-redirect payloads."""
|
||||
|
||||
@staticmethod
|
||||
def _login_js():
|
||||
return (Path(__file__).parent.parent / "static" / "login.js").read_text(encoding="utf-8")
|
||||
|
||||
def test_safe_next_path_function_exists(self):
|
||||
"""login.js must define _safeNextPath() to honor the ?next= redirect."""
|
||||
assert "_safeNextPath" in self._login_js(), (
|
||||
"login.js must define _safeNextPath() to use the ?next= redirect after login"
|
||||
)
|
||||
|
||||
def test_login_uses_safe_next_path(self):
|
||||
"""doLogin success handler must redirect to _safeNextPath(), not hardcoded './'."""
|
||||
src = self._login_js()
|
||||
assert "_safeNextPath()" in src, (
|
||||
"doLogin must call _safeNextPath() instead of hardcoding './'"
|
||||
)
|
||||
|
||||
def test_safe_next_path_rejects_protocol_relative(self):
|
||||
"""_safeNextPath guard must reject '//' prefix (protocol-relative open-redirect)."""
|
||||
src = self._login_js()
|
||||
assert "charAt(1) === '/'" in src or "startsWith('//')" in src, (
|
||||
"_safeNextPath must reject protocol-relative paths like //evil.com"
|
||||
)
|
||||
|
||||
def test_safe_next_path_rejects_non_path_absolute(self):
|
||||
"""_safeNextPath guard must require path starts with '/'."""
|
||||
src = self._login_js()
|
||||
assert "charAt(0) !== '/'" in src or "startsWith('/')" in src, (
|
||||
"_safeNextPath must reject non-path-absolute inputs (e.g. 'http://...')"
|
||||
)
|
||||
51
tests/test_1044_mermaid_csp_font.py
Normal file
51
tests/test_1044_mermaid_csp_font.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""
|
||||
Tests for issue #1044 — Mermaid CSP font violation.
|
||||
|
||||
Mermaid's built-in themes inject an @import for Google Fonts (Manrope) at
|
||||
render time, which is blocked by the CSP's style-src directive. Fix: pass
|
||||
fontFamily:'inherit' in themeVariables so Mermaid never requests an external
|
||||
font URL.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _ui_js() -> str:
|
||||
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestMermaidCSPFont:
|
||||
def test_mermaid_init_has_font_family_inherit(self):
|
||||
"""themeVariables in mermaid.initialize() must set fontFamily to 'inherit'."""
|
||||
src = _ui_js()
|
||||
assert "fontFamily:'inherit'" in src, (
|
||||
"mermaid.initialize() themeVariables must set fontFamily:'inherit' "
|
||||
"to suppress the Google Fonts (Manrope) import that violates CSP"
|
||||
)
|
||||
|
||||
def test_mermaid_init_no_google_fonts_url(self):
|
||||
"""ui.js must not contain a hardcoded fonts.googleapis.com URL."""
|
||||
src = _ui_js()
|
||||
assert "fonts.googleapis.com" not in src, (
|
||||
"ui.js must not reference fonts.googleapis.com — use fontFamily:'inherit'"
|
||||
)
|
||||
|
||||
def test_mermaid_font_family_inside_theme_variables_block(self):
|
||||
"""fontFamily:'inherit' must be inside the themeVariables block of mermaid.initialize()."""
|
||||
src = _ui_js()
|
||||
init_idx = src.find("mermaid.initialize(")
|
||||
assert init_idx != -1, "mermaid.initialize() call not found in ui.js"
|
||||
# Find the themeVariables block after the initialize call
|
||||
tv_idx = src.find("themeVariables", init_idx)
|
||||
assert tv_idx != -1, "themeVariables not found inside mermaid.initialize()"
|
||||
font_idx = src.find("fontFamily:'inherit'", tv_idx)
|
||||
assert font_idx != -1, (
|
||||
"fontFamily:'inherit' must appear inside themeVariables in mermaid.initialize()"
|
||||
)
|
||||
# The closing brace of themeVariables should come after fontFamily
|
||||
close_brace = src.find("})", tv_idx)
|
||||
assert font_idx < close_brace, (
|
||||
"fontFamily:'inherit' must be inside the themeVariables block (before })"
|
||||
)
|
||||
116
tests/test_1045_bfcache_layout_restore.py
Normal file
116
tests/test_1045_bfcache_layout_restore.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Tests for issue #1045 — bfcache layout broken on tab restore.
|
||||
|
||||
When the browser restores a page from bfcache (event.persisted === true),
|
||||
the async boot IIFE does not re-run. The existing pageshow handler (added for
|
||||
#822) only cleared the session search field and re-rendered the session list.
|
||||
This left the rail, topbar, workspace panel, and resize handles in the stale
|
||||
bfcache DOM state, producing a broken layout.
|
||||
|
||||
Fix: extend the pageshow handler to also call syncTopbar, syncWorkspacePanelState,
|
||||
_initResizePanels, and startGatewaySSE — all guarded so missing helpers degrade.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _boot_js() -> str:
|
||||
return (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestBfcacheLayoutRestore:
|
||||
def test_pageshow_calls_sync_topbar(self):
|
||||
"""pageshow handler must call syncTopbar() on bfcache restore."""
|
||||
src = _boot_js()
|
||||
# Find the pageshow listener block
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
assert ps_idx != -1, "pageshow listener not found in boot.js"
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "syncTopbar" in handler_body, (
|
||||
"pageshow handler must call syncTopbar() to restore topbar state after bfcache"
|
||||
)
|
||||
|
||||
def test_pageshow_calls_sync_workspace_panel_state(self):
|
||||
"""pageshow handler must call syncWorkspacePanelState()."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "syncWorkspacePanelState" in handler_body, (
|
||||
"pageshow handler must call syncWorkspacePanelState() on bfcache restore"
|
||||
)
|
||||
|
||||
|
||||
def test_pageshow_calls_start_gateway_sse(self):
|
||||
"""pageshow handler must call startGatewaySSE() to reconnect the dead SSE connection."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "startGatewaySSE" in handler_body, (
|
||||
"pageshow handler must restart gateway SSE (bfcache-persisted connections are dead)"
|
||||
)
|
||||
|
||||
def test_pageshow_still_clears_session_search(self):
|
||||
"""pageshow handler must still clear #sessionSearch (original #822 fix preserved)."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "sessionSearch" in handler_body, (
|
||||
"pageshow handler must still clear #sessionSearch (regression: #822 fix must be preserved)"
|
||||
)
|
||||
|
||||
def test_pageshow_still_calls_render_session_list_from_cache(self):
|
||||
"""pageshow handler must still call renderSessionListFromCache()."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "renderSessionListFromCache" in handler_body, (
|
||||
"pageshow handler must still call renderSessionListFromCache() (regression: #822 fix)"
|
||||
)
|
||||
|
||||
def test_pageshow_does_not_call_init_resize_panels(self):
|
||||
"""pageshow handler must NOT call _initResizePanels() — bfcache
|
||||
preserves event listeners so re-attaching them stacks duplicates."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "_initResizePanels" not in handler_body, (
|
||||
"pageshow handler must not call _initResizePanels() — it stacks "
|
||||
"duplicate mousedown listeners on every bfcache restore"
|
||||
)
|
||||
|
||||
def test_new_calls_are_guarded_with_typeof(self):
|
||||
"""New calls in the pageshow handler must be typeof-guarded for safe degradation."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
# Each of the new calls must be guarded
|
||||
for fn in ("syncTopbar", "syncWorkspacePanelState", "startGatewaySSE",
|
||||
"closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
|
||||
assert f"typeof {fn} === 'function'" in handler_body, (
|
||||
f"{fn}() call in pageshow handler must be guarded with typeof === 'function'"
|
||||
)
|
||||
|
||||
def test_pageshow_closes_all_dropdowns(self):
|
||||
"""pageshow handler must close all known dropdowns to reset frozen bfcache popover state."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
for fn in ("closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
|
||||
assert fn in handler_body, (
|
||||
f"pageshow handler must call {fn}() to dismiss any dropdown left open by bfcache"
|
||||
)
|
||||
|
||||
def test_dropdowns_closed_before_layout_sync(self):
|
||||
"""Dropdown closes must come before layout sync calls (clean state first)."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
close_idx = handler_body.find("closeModelDropdown")
|
||||
sync_idx = handler_body.find("syncTopbar")
|
||||
assert close_idx != -1 and sync_idx != -1, "Both close and sync calls must be present"
|
||||
assert close_idx < sync_idx, (
|
||||
"Dropdown close calls must appear before layout sync calls in the pageshow handler"
|
||||
)
|
||||
|
||||
148
tests/test_cron_session_title.py
Normal file
148
tests/test_cron_session_title.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Tests for cron session title fallback in get_cli_sessions().
|
||||
|
||||
When a CLI session originates from cron and has no title in state.db, the
|
||||
WebUI sidebar should display the human-friendly job name from cron/jobs.json
|
||||
instead of a generic "Cron Session" label.
|
||||
|
||||
Session ID format produced by hermes-agent: cron_<job_id>_<YYYYMMDD>_<HHMMSS>
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
import api.models as models
|
||||
|
||||
|
||||
def _make_state_db(path, sessions):
|
||||
"""Create a state.db with the schema get_cli_sessions() expects.
|
||||
|
||||
`sessions` is a list of (id, title, source) tuples.
|
||||
"""
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.execute("""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
message_count INTEGER,
|
||||
started_at REAL,
|
||||
source TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
timestamp REAL
|
||||
)
|
||||
""")
|
||||
for sid, title, source in sessions:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, title, model, message_count, started_at, source) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(sid, title, "gpt-x", 1, 1700000000.0, source),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, timestamp) VALUES (?, ?)",
|
||||
(sid, 1700000001.0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _write_jobs_json(hermes_home, jobs):
|
||||
"""Write cron/jobs.json with the given jobs list."""
|
||||
cron_dir = hermes_home / "cron"
|
||||
cron_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cron_dir / "jobs.json").write_text(
|
||||
json.dumps({"jobs": jobs}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_hermes_home(tmp_path, monkeypatch):
|
||||
"""Point get_cli_sessions() at a temporary HERMES_HOME and disable
|
||||
profile lookups so the test runs hermetically."""
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
|
||||
# Both profile helpers are imported lazily inside get_cli_sessions(),
|
||||
# so patching the api.profiles module reaches them.
|
||||
import api.profiles as profiles
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: home)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None)
|
||||
|
||||
return home
|
||||
|
||||
|
||||
def test_cron_session_uses_job_name_when_title_missing(fake_hermes_home):
|
||||
"""A cron session with no title should display the friendly job name."""
|
||||
_write_jobs_json(fake_hermes_home, [
|
||||
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
|
||||
])
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_cd65df6fc1a8_20260417_191049", None, "cron"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["title"] == "wiki-auto-ingest"
|
||||
|
||||
|
||||
def test_cron_session_falls_back_when_jobs_json_missing(fake_hermes_home):
|
||||
"""No jobs.json should not crash; title falls back to 'Cron Session'."""
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_abc123_20260417_191049", None, "cron"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert sessions[0]["title"] == "Cron Session"
|
||||
|
||||
|
||||
def test_cron_session_falls_back_when_job_id_not_in_jobs_json(fake_hermes_home):
|
||||
"""Stale session whose job has been deleted falls back gracefully."""
|
||||
_write_jobs_json(fake_hermes_home, [
|
||||
{"id": "different_job", "name": "Some Other Job"},
|
||||
])
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_orphan_20260417_191049", None, "cron"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert sessions[0]["title"] == "Cron Session"
|
||||
|
||||
|
||||
def test_explicit_title_is_preserved(fake_hermes_home):
|
||||
"""If state.db already has a title, the cron job lookup should not
|
||||
override it."""
|
||||
_write_jobs_json(fake_hermes_home, [
|
||||
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
|
||||
])
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_cd65df6fc1a8_20260417_191049", "User-edited title", "cron"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert sessions[0]["title"] == "User-edited title"
|
||||
|
||||
|
||||
def test_non_cron_sessions_unaffected(fake_hermes_home):
|
||||
"""The cron-name lookup must not run for cli-source sessions, so the
|
||||
generic 'Cli Session' fallback still applies when title is empty."""
|
||||
_write_jobs_json(fake_hermes_home, [
|
||||
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
|
||||
])
|
||||
# A 'cli' session whose ID coincidentally starts with 'cron_' must not
|
||||
# pick up the job name — the source check guards against this.
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_cd65df6fc1a8_xx", None, "cli"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert sessions[0]["title"] == "Cli Session"
|
||||
|
|
@ -192,10 +192,22 @@ class TestOnboardingStatusUnsupportedProvider:
|
|||
"config.yaml + chat_ready=True must auto-complete onboarding regardless of provider."
|
||||
)
|
||||
|
||||
def test_minimax_cn_not_ready_shows_wizard(self):
|
||||
"""minimax-cn + chat_ready=False → wizard fires so user can fix it."""
|
||||
def test_minimax_cn_not_ready_skips_wizard(self):
|
||||
"""minimax-cn + chat_ready=False → wizard still skipped for non-wizard providers.
|
||||
|
||||
The onboarding wizard has no minimax-cn option — showing it would only confuse
|
||||
the user or let them accidentally overwrite their config with an OpenAI/Anthropic
|
||||
provider. For any provider not in _SUPPORTED_PROVIDER_SETUPS, onboarding is
|
||||
auto-completed as long as provider_configured is True, regardless of chat_ready.
|
||||
Users on non-wizard providers with no API key should fix credentials via
|
||||
Settings → Providers, not via the first-run wizard. (#1020)
|
||||
"""
|
||||
result = self._make_status(chat_ready=False)
|
||||
assert result["completed"] is False
|
||||
assert result["completed"] is True, (
|
||||
"Wizard fired for minimax-cn user with provider_configured=True! "
|
||||
"Non-wizard providers must auto-complete onboarding because the wizard "
|
||||
"cannot configure them and would silently overwrite their config."
|
||||
)
|
||||
|
||||
def test_current_is_oauth_set_for_unsupported_provider(self):
|
||||
"""setup.current_is_oauth must be True for minimax-cn (not in quick-setup list)."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue