v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)

* fix: remove orphaned i18n keys from top-level LOCALES object

Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.

The correct translations already exist in the zh-Hant locale block.

Fixes #1008

* fix: block stale SSE events from polluting new session's DOM

- appendThinking(): guard with !S.session||!S.activeStreamId to drop
  events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
  scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
  streaming if user is watching (scroll pinned)

* Fix empty agent sessions in sidebar

* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status

Fixes #995 — three sub-issues in the Cron Jobs UI:

1. Dual play icons ambiguous: Resume button now shows a distinct
   play+bar icon (play triangle + vertical line) instead of the
   identical triangle used by Run now.

2. Toast notification overlapping header buttons: Added
   position:relative; z-index:10 to .main-view-header so it
   stacks above the fixed toast (z-index:100 within its layer).

3. No running status after trigger: After triggering a job, the
   status badge immediately shows 'running…' with a CSS spinner
   animation, and polls the cron list every 3s (up to 30s) to
   refresh when the job completes.

- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()

* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback

- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback

* feat: create folder and add space directly from UI (#782)

- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales

* fix: validate workspace path before mkdir to prevent orphan directories

Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.

New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir

Also imports _workspace_blocked_roots for the pre-mutation blocklist check.

* fix(#1014): classify model-not-found errors with helpful message

- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n

* feat(ui): add live TPS stat to header

Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.

Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
  window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock

Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
  updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end

Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
  focus/blur was replaced with simple always-1s-when-active approach)

UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)

* feat: session restore speed + title gen reasoning hardening (#1025, #1026)

PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session

PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback

Co-authored-by: Frank Song <franksong2702@gmail.com>

* feat: session attention indicators in right slot + last_message_at timestamps (#1024)

PR #1024 (@franksong2702): Polish session attention indicators

- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
  so metadata-only saves don't make old sessions appear under Today

Co-authored-by: Frank Song <franksong2702@gmail.com>

* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-25 13:07:35 -07:00
committed by GitHub
parent 12a8c051fb
commit ad8e10304c
24 changed files with 1655 additions and 261 deletions
+18 -1
View File
@@ -3,7 +3,24 @@
## [Unreleased]
### Fixed
- **Reasoning chip now appears after the model chip** in the composer toolbar — model is a more fundamental choice and should be stable in position regardless of whether reasoning is active. Order: Profile → Workspace → Model → Reasoning. (`static/index.html`)
## v0.50.207 — 2026-04-25
### Added
- **Live TPS stat in header** — a monospace chip in the titlebar shows tokens per second during streaming, with HIGH watermark from the past hour. Emitted via SSE at 1 Hz during active streams; hidden when idle. (`api/metering.py`, `api/streaming.py`, `static/messages.js`, `static/style.css`) [#1005 @JKJameson]
### Fixed
- **Stale SSE events no longer pollute the new session's DOM on session switch** — `appendThinking()` and `appendLiveToolCard()` now guard against events from a prior session's stream arriving after the user has switched sessions. Thinking card also auto-scrolls to top on completion so the response is immediately visible. (`static/ui.js`) [#1006 @JKJameson]
- **Show agent sessions no longer shows empty/unimportable rows** — `state.db` can contain agent session rows before any messages are written. The sidebar now filters those out consistently across both the regular `/api/sessions` path and the gateway SSE watcher. (`api/agent_sessions.py`, `api/gateway_watcher.py`, `api/models.py`) [#1009 @franksong2702]
- **Three orphaned i18n keys removed from language dropdown** — `cmd_status`, `memory_saved`, and `profile_delete_title` were placed outside any locale block in `static/i18n.js`, causing them to appear as invalid language options. (`static/i18n.js`) [#1010 @bergeouss]
- **Cron panel UX polish** — Resume button SVG now uses a ▶| icon to distinguish it from Run; toast overlap fixed with `z-index` on the header; running-state badge with spinner shows during active jobs; `_cronRunningPoll` clears correctly on panel close. (`static/panels.js`, `static/index.html`, `static/style.css`, `static/i18n.js`) [#1011 @bergeouss]
- **Create Folder and Add as Space from the browser** — users can now create directories and immediately register them as workspace spaces without SSH access; server validates paths against blocked roots before `mkdir`. (`api/routes.py`, `static/ui.js`, `static/panels.js`, `static/i18n.js`) [#1018 @bergeouss]
- **Model-not-found errors now show a helpful message** — when a provider returns a 404 (e.g. Qwen model not available), the error is classified and a user-friendly hint appears instead of a raw HTML page. All 6 locales covered. (`api/streaming.py`, `static/messages.js`, `static/i18n.js`) [#1022 @bergeouss]
- **Session attention indicators moved to right-side actions slot** — streaming spinners and unread dots no longer sit before the session title, avoiding title shifts. Running/unread rows hide the timestamp; idle/read rows keep right-aligned timestamps. Date group carets now point down/right correctly. Pinned group no longer repeats the star icon per row. (`static/sessions.js`, `static/style.css`) [#1024 @franksong2702]
- **Session sidebar dates now use the last real message time** — sorting, grouping, and relative timestamps prefer `last_message_at` derived from the last non-tool message instead of metadata-only `updated_at`, so changing session settings doesn't move old conversations to Today. (`api/models.py`, `api/routes.py`) [#1024 @franksong2702]
- **Running indicators appear immediately after send** — the sidebar now treats the active local busy session and local in-flight sessions as streaming while `/api/sessions` catches up. (`static/messages.js`, `static/sessions.js`) [#1024 @franksong2702]
- **Large session switching and reload no longer block on cold model-catalog resolution** — `GET /api/session?messages=0` now parses only the JSON metadata prefix; metadata-only loads skip the full-session LRU cache; the frontend lazy fetch passes `resolve_model=0`; hard reload no longer waits for `populateModelDropdown()`. (`api/models.py`, `api/routes.py`, `static/boot.js`, `static/sessions.js`, `static/ui.js`) [#1025 @franksong2702]
- **Auto title generation hardened for reasoning models** — title generation now uses a 512-token reasoning-safe budget, retries once with 1024 tokens on empty content or `finish_reason: length`, and preserves the underlying failure reason in `title_status` when falling back to a local summary. (`api/streaming.py`) [#1026 @franksong2702]
## v0.50.206 — 2026-04-25
+1 -1
View File
@@ -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: 2107 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: 2169 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.
+55
View File
@@ -0,0 +1,55 @@
"""Shared helpers for reading Hermes Agent sessions from state.db."""
import logging
import sqlite3
from pathlib import Path
logger = logging.getLogger(__name__)
def read_importable_agent_session_rows(db_path: Path, limit: int = 200, log=None) -> list[dict]:
"""Return non-WebUI agent sessions that have readable message rows.
Hermes Agent can create rows in ``state.db.sessions`` before a session has
any messages. WebUI cannot import those rows, so both the regular
``/api/sessions`` path and the gateway SSE watcher must filter them the
same way.
"""
db_path = Path(db_path)
if not db_path.exists():
return []
log = log or logger
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Older Hermes Agent versions may not have source tracking. Without a
# source column we cannot safely distinguish WebUI rows from agent rows.
cur.execute("PRAGMA table_info(sessions)")
session_cols = {row[1] for row in cur.fetchall()}
if 'source' not in session_cols:
log.warning(
"agent session listing skipped: state.db at %s has no 'source' column "
"(older hermes-agent?). Agent sessions unavailable. "
"Upgrade hermes-agent to fix this.",
db_path,
)
return []
cur.execute(
"""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
COUNT(m.id) AS actual_message_count,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.source IS NOT NULL AND s.source != 'webui'
GROUP BY s.id
HAVING COUNT(m.id) > 0
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
LIMIT ?
""",
(int(limit),),
)
return [dict(row) for row in cur.fetchall()]
+13 -28
View File
@@ -13,12 +13,12 @@ import json
import logging
import os
import queue
import sqlite3
import threading
import time
from pathlib import Path
from api.config import HOME
from api.agent_sessions import read_importable_agent_session_rows
logger = logging.getLogger(__name__)
@@ -55,33 +55,18 @@ def _get_agent_sessions_from_db() -> list:
return []
try:
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.source IS NOT NULL AND s.source != 'webui'
GROUP BY s.id
HAVING COUNT(m.id) > 0
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
LIMIT 200
""")
sessions = []
for row in cur.fetchall():
sessions.append({
'session_id': row['id'],
'title': row['title'] or 'Agent Session',
'model': row['model'] or None,
'message_count': row['message_count'] or 0,
'created_at': row['started_at'],
'updated_at': row['last_activity'] or row['started_at'],
'source': row['source'] or 'cli',
})
return sessions
sessions = []
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
sessions.append({
'session_id': row['id'],
'title': row['title'] or 'Agent Session',
'model': row['model'] or None,
'message_count': row['message_count'] or row['actual_message_count'] or 0,
'created_at': row['started_at'],
'updated_at': row['last_activity'] or row['started_at'],
'source': row['source'] or 'cli',
})
return sessions
except Exception:
return []
+187
View File
@@ -0,0 +1,187 @@
"""
Hermes Web UI -- Streaming performance metering.
Tracks Tokens Per Second (TPS) across all active WebUI sessions, and the
HIGH/LOW TPS values observed over the past 60 minutes. Metering data is
emitted via SSE events so the header label can update live during a stream.
Architecture
Each streaming session is tracked independently. TPS per session is:
session_tps = total_tokens / (last_token_ts - first_token_ts)
The global tps is the average of all currently active sessions' TPS values.
This correctly represents the system's real-time capacity regardless of how
many sessions are running or how long each has been streaming.
For HIGH/LOW tracking, every stats snapshot records the current global tps
(only when > 0 idle periods are skipped) into a rolling 60-minute history.
The max/min of that history gives the peak throughput observed over the past hour.
The ticker in streaming.py calls get_interval() it returns 1.0 when sessions
are actively receiving tokens so the header updates at 1 Hz, and 10.0 when idle
so the ticker exits and no idle readings are emitted.
Usage from api/streaming.py
from api.metering import meter
meter().begin_session(stream_id) # stream starts
meter().record_token(stream_id, running_output) # per output token
meter().record_reasoning(stream_id, running_reasoning_len) # per reasoning token
The SSE `metering` event payload:
{
"tps": 47.3, # average TPS across active sessions (real-time)
"high": 52.1, # highest average TPS observed in the past 60 minutes
"low": 31.4, # lowest average TPS (excl. readings < 1 tps, to ignore idle)
"active": 1, # sessions currently streaming
}
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
_HOUR_SECS = 3600.0 # rolling window for HIGH/LOW tracking
_STALE_SECS = 60.0 # consider a session inactive after this
@dataclass
class _SessionMeter:
output_tokens: int = 0
reasoning_tokens: int = 0
first_token_ts: float = 0.0 # time.monotonic() of first token received
last_token_ts: float = 0.0 # time.monotonic() of last token received
def total_tokens(self) -> int:
return self.output_tokens + self.reasoning_tokens
def tps(self) -> float:
if self.first_token_ts == 0.0 or self.last_token_ts <= self.first_token_ts:
return 0.0
return self.total_tokens() / (self.last_token_ts - self.first_token_ts)
class GlobalMeter:
"""Thread-safe global streaming meter.
Tracks per-session TPS, averages them for a global tps, and maintains a
60-minute rolling history of global tps snapshots for HIGH/LOW reporting.
"""
__slots__ = (
'_lock',
'_sessions', # stream_id -> _SessionMeter
'_readings', # [(monotonic_ts, tps), ...] rolling 60-minute history
'_window_start', # monotonic ts of current window
)
def __init__(self) -> None:
self._lock = threading.Lock()
self._sessions: dict[str, _SessionMeter] = {}
self._readings: list[tuple[float, float]] = []
self._window_start: float = time.monotonic()
# ── Public API ────────────────────────────────────────────────────────────
def begin_session(self, stream_id: str) -> None:
with self._lock:
self._sessions[stream_id] = _SessionMeter()
def get_interval(self) -> float:
"""Return 1.0 when sessions are actively receiving tokens, 10.0 when idle.
Used by the streaming ticker to run at 1 Hz during work and exit when
there is nothing to measure.
"""
now = time.monotonic()
with self._lock:
# Only count sessions that have received at least one token recently.
active_sids = {
sid for sid, s in self._sessions.items()
if s.first_token_ts > 0 and (now - s.last_token_ts) <= _STALE_SECS
}
return 1.0 if active_sids else 10.0
def record_token(self, stream_id: str, running_output_tokens: int) -> None:
now = time.monotonic()
with self._lock:
s = self._sessions.get(stream_id)
if s is None:
return
if s.first_token_ts == 0.0:
s.first_token_ts = now
s.last_token_ts = now
s.output_tokens = running_output_tokens
def record_reasoning(self, stream_id: str, running_reasoning_tokens: int) -> None:
now = time.monotonic()
with self._lock:
s = self._sessions.get(stream_id)
if s is None:
return
if s.first_token_ts == 0.0:
s.first_token_ts = now
s.last_token_ts = now
s.reasoning_tokens = running_reasoning_tokens
def end_session(self, stream_id: str, final_output_tokens: int, input_tokens: int = 0) -> None:
with self._lock:
self._sessions.pop(stream_id, None)
def get_stats(self) -> dict:
now = time.monotonic()
with self._lock:
# Prune stale sessions
stale = [
sid for sid, s in self._sessions.items()
if s.first_token_ts > 0 and (now - s.last_token_ts) > _STALE_SECS
]
for sid in stale:
self._sessions.pop(sid, None)
# Reset window if everything went stale
if not self._sessions:
self._window_start = now
# Compute global tps: average of per-session TPS values
active = [s for s in self._sessions.values() if s.first_token_ts > 0]
if active:
global_tps = sum(s.tps() for s in active) / len(active)
else:
global_tps = 0.0
# Prune readings older than 1 hour
cutoff = now - _HOUR_SECS
self._readings = [(ts, v) for ts, v in self._readings if ts > cutoff]
# Only record this snapshot for HIGH/LOW if there is active work.
# This prevents idle periods from flooding the history and keeps
# HIGH/LOW meaningful for the past hour of actual throughput.
if global_tps > 0:
self._readings.append((now, global_tps))
# HIGH/LOW from the past hour (skip near-zero idle readings)
active_readings = [v for _, v in self._readings if v >= 1.0]
high = max(active_readings) if active_readings else 0.0
low = min(active_readings) if active_readings else 0.0
return {
'tps': round(global_tps, 1),
'high': round(high, 1),
'low': round(low, 1),
'active': len(self._sessions),
}
# ── Module-level singleton ─────────────────────────────────────────────────────
_meter = GlobalMeter()
def meter() -> GlobalMeter:
return _meter
+172 -84
View File
@@ -15,6 +15,7 @@ from api.config import (
get_effective_default_model,
)
from api.workspace import get_last_workspace
from api.agent_sessions import read_importable_agent_session_rows
logger = logging.getLogger(__name__)
@@ -193,6 +194,114 @@ def _active_stream_ids():
def _is_streaming_session(active_stream_id, active_stream_ids):
return bool(active_stream_id and active_stream_id in active_stream_ids)
def _session_sort_timestamp(session):
if isinstance(session, dict):
return session.get('last_message_at') or session.get('updated_at') or 0
return _last_message_timestamp(getattr(session, 'messages', None)) or getattr(session, 'updated_at', 0) or 0
def _message_timestamp(message):
if not isinstance(message, dict):
return None
raw = message.get('_ts') or message.get('timestamp')
try:
return float(raw) if raw is not None else None
except (TypeError, ValueError):
return None
def _last_message_timestamp(messages):
if not isinstance(messages, list):
return None
for message in reversed(messages):
if isinstance(message, dict) and message.get('role') == 'tool':
continue
ts = _message_timestamp(message)
if ts:
return ts
return None
def _find_top_level_json_key(text, key):
"""Return the byte offset of a top-level JSON object key, if present."""
depth = 0
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch == '"':
start = i
i += 1
escaped = False
chars = []
while i < n:
c = text[i]
if escaped:
chars.append(c)
escaped = False
elif c == '\\':
escaped = True
elif c == '"':
break
else:
chars.append(c)
i += 1
if i >= n:
return None
if depth == 1 and ''.join(chars) == key:
j = i + 1
while j < n and text[j] in ' \t\r\n':
j += 1
if j < n and text[j] == ':':
return start
elif ch in '{[':
depth += 1
elif ch in '}]':
depth -= 1
i += 1
return None
def _read_metadata_json_prefix(path, max_prefix_bytes=65536):
"""Read only the metadata portion before the top-level messages array."""
buf = ''
with open(path, 'r', encoding='utf-8') as f:
while len(buf.encode('utf-8')) < max_prefix_bytes:
chunk = f.read(4096)
if not chunk:
return None
buf += chunk
messages_pos = _find_top_level_json_key(buf, 'messages')
if messages_pos is None:
continue
prefix = buf[:messages_pos].rstrip()
if prefix.endswith(','):
prefix = prefix[:-1].rstrip()
return f'{prefix}\n}}'
return None
def _lookup_index_message_count(session_id):
"""Return the indexed message count without loading the full session file."""
try:
entries = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
except Exception:
return None
if not isinstance(entries, list):
return None
for entry in entries:
if entry.get('session_id') != session_id:
continue
count = entry.get('message_count')
if isinstance(count, int) and count >= 0:
return count
try:
count = int(count)
except (TypeError, ValueError):
return None
return count if count >= 0 else None
return None
class Session:
def __init__(self, session_id: str=None, title: str='Untitled',
@@ -231,6 +340,7 @@ class Session:
self.pending_started_at = pending_started_at
self.compression_anchor_visible_idx = compression_anchor_visible_idx
self.compression_anchor_message_key = compression_anchor_message_key
self._metadata_message_count = None
@property
def path(self):
@@ -255,7 +365,8 @@ class Session:
meta['tool_calls'] = self.tool_calls
# Fields not in METADATA_FIELDS (e.g. last_usage, message_count) go at the end
extra = {k: v for k, v in self.__dict__.items()
if k not in METADATA_FIELDS and k not in ('messages', 'tool_calls')}
if k not in METADATA_FIELDS and k not in ('messages', 'tool_calls')
and not k.startswith('_')}
payload = json.dumps({**meta, **extra}, ensure_ascii=False, indent=2)
tmp = self.path.with_suffix(f'.tmp.{os.getpid()}.{threading.current_thread().ident}')
try:
@@ -288,10 +399,9 @@ class Session:
"""Load only the compact metadata fields, skipping the messages array.
Session JSON files have metadata fields (session_id, title, model, etc.)
at the top level, before the large messages array. We read only the
first ~1KB enough to capture all compact() fields then parse just
that prefix. Falls back to load() if the prefix doesn't contain enough
fields or if the file is unexpectedly small.
at the top level, before the large messages array. Read only up to the
top-level "messages" field and synthesize a small metadata-only object.
Falls back to load() for legacy or unexpected file layouts.
"""
if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
return None
@@ -299,26 +409,18 @@ class Session:
if not p.exists():
return None
try:
# Read just the first 1 KB — metadata comes before messages array
with open(p, 'r', encoding='utf-8') as f:
prefix = f.read(1024)
prefix = _read_metadata_json_prefix(p)
if not prefix:
return cls.load(sid)
parsed = json.loads(prefix)
# Verify we got the essential fields.
# With metadata-first save() ordering, messages appears at byte ~567.
# For sessions <= ~512 bytes total the entire messages array fits in the
# first 1 KB and we get a valid list. For larger sessions json.loads
# fails on the truncated buffer (unterminated string), so we fall back
# to full load. The one exception is a truncation inside a string value
# that happens to produce valid JSON with a truncated string — guard
# against that by requiring messages to be a list.
needed = {'session_id', 'title', 'created_at', 'updated_at'}
if not needed.issubset(parsed.keys()):
return cls.load(sid)
if not isinstance(parsed.get('messages'), list):
return cls.load(sid)
return cls(**parsed)
parsed['messages'] = []
parsed['tool_calls'] = []
session = cls(**parsed)
session._metadata_message_count = _lookup_index_message_count(sid)
return session
except Exception:
# Corrupt prefix or decode error — fall back to full load
return cls.load(sid)
@@ -330,9 +432,14 @@ class Session:
'title': self.title,
'workspace': self.workspace,
'model': self.model,
'message_count': len(self.messages),
'message_count': (
self._metadata_message_count
if self._metadata_message_count is not None
else len(self.messages)
),
'created_at': self.created_at,
'updated_at': self.updated_at,
'last_message_at': _last_message_timestamp(self.messages) or self.updated_at,
'pinned': self.pinned,
'archived': self.archived,
'project_id': self.project_id,
@@ -352,9 +459,10 @@ class Session:
def get_session(sid, metadata_only=False):
"""Load a session, optionally with metadata only (skipping the messages array).
When metadata_only=True the session is still cached so the full load on the
next access is fast. Use this when you only need compact() metadata and not
the actual message history (e.g., for fast sidebar switching).
Metadata-only loads intentionally do not populate the full-session cache.
Otherwise a later full load could return a compact object with an empty
messages list. Use this when you only need compact() metadata and not the
actual message history (e.g., for fast sidebar switching).
"""
with LOCK:
if sid in SESSIONS:
@@ -362,6 +470,8 @@ def get_session(sid, metadata_only=False):
return SESSIONS[sid]
if metadata_only:
s = Session.load_metadata_only(sid)
if s:
return s
else:
s = Session.load(sid)
if s:
@@ -413,6 +523,18 @@ def all_sessions():
s for s in index
if _index_entry_exists(s.get('session_id'))
]
backfilled = []
for i, s in enumerate(index):
if 'last_message_at' not in s:
full = Session.load(s.get('session_id'))
if full:
index[i] = full.compact()
backfilled.append(full)
if backfilled:
try:
_write_session_index(updates=backfilled)
except Exception:
logger.debug("Failed to persist last_message_at backfill")
for s in index:
s['is_streaming'] = _is_streaming_session(
s.get('active_stream_id'),
@@ -426,7 +548,7 @@ def all_sessions():
include_runtime=True,
active_stream_ids=active_stream_ids,
)
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), _session_sort_timestamp(s)), reverse=True)
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
# Exempt sessions younger than 60 s so a brand-new session stays visible (#789)
_now = time.time()
@@ -454,7 +576,7 @@ def all_sessions():
logger.debug("Failed to load session from %s", p)
for s in SESSIONS.values():
if all(s.session_id != x.session_id for x in out): out.append(s)
out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True)
out.sort(key=lambda s: (getattr(s, 'pinned', False), _session_sort_timestamp(s)), reverse=True)
_now = time.time()
result = [s.compact(include_runtime=True, active_stream_ids=active_stream_ids) for s in out if not (
s.title == 'Untitled'
@@ -528,16 +650,11 @@ def get_cli_sessions() -> list:
"""Read CLI sessions from the agent's SQLite store and return them as
dicts in a format the WebUI sidebar can render alongside local sessions.
Returns empty list if the SQLite DB is missing, the sqlite3 module is
unavailable, or any error occurs -- the bridge is purely additive and never
crashes the WebUI.
Returns empty list if the SQLite DB is missing or any error occurs -- the
bridge is purely additive and never crashes the WebUI.
"""
import os
cli_sessions = []
try:
import sqlite3
except ImportError:
return cli_sessions
# Use the active WebUI profile's HERMES_HOME to find state.db.
# The active profile is determined by what the user has selected in the UI
@@ -566,59 +683,30 @@ def get_cli_sessions() -> list:
_cli_profile = None # older agent -- fall back to no profile
try:
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Introspect schema to handle older hermes-agent versions that
# may not have a 'source' column. Without this check the query raises
# OperationalError which is silently swallowed, causing the empty-list bug.
cur.execute("PRAGMA table_info(sessions)")
_session_cols = {row[1] for row in cur.fetchall()}
if 'source' not in _session_cols:
import logging as _logging
_logging.getLogger(__name__).warning(
"get_cli_sessions(): state.db at %s has no 'source' column "
"(older hermes-agent?). CLI sessions unavailable. "
"Upgrade hermes-agent to fix this.",
db_path,
)
return cli_sessions
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
sid = row['id']
raw_ts = row['last_activity'] or row['started_at']
# Prefer the CLI session's own profile from the DB; fall back to
# the active CLI profile so sidebar filtering works either way.
profile = _cli_profile # CLI DB has no profile column; use active profile
cur.execute("""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.source IS NOT NULL AND s.source != 'webui'
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
LIMIT 200
""")
for row in cur.fetchall():
sid = row['id']
raw_ts = row['last_activity'] or row['started_at']
# Prefer the CLI session's own profile from the DB; fall back to
# the active CLI profile so sidebar filtering works either way.
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'
cli_sessions.append({
'session_id': sid,
'title': _display_title,
'workspace': str(get_last_workspace()),
'model': row['model'] or None,
'message_count': row['message_count'] or 0,
'created_at': row['started_at'],
'updated_at': raw_ts,
'pinned': False,
'archived': False,
'project_id': None,
'profile': profile,
'source_tag': _source,
'is_cli_session': True,
})
_source = row['source'] or 'cli'
_display_title = row['title'] or f'{_source.title()} Session'
cli_sessions.append({
'session_id': sid,
'title': _display_title,
'workspace': str(get_last_workspace()),
'model': row['model'] or None,
'message_count': row['message_count'] or row['actual_message_count'] or 0,
'created_at': row['started_at'],
'updated_at': raw_ts,
'pinned': False,
'archived': False,
'project_id': None,
'profile': profile,
'source_tag': _source,
'is_cli_session': True,
})
except Exception as _cli_err:
# DB schema changed, locked, or corrupted -- log warning so admins can diagnose.
# Still degrade gracefully (don't crash the WebUI).
+34 -4
View File
@@ -329,6 +329,7 @@ from api.workspace import (
safe_resolve_ws,
resolve_trusted_workspace,
validate_workspace_to_add,
_workspace_blocked_roots,
)
from api.upload import handle_upload, handle_transcribe
from api.streaming import _sse, _run_agent_streaming, cancel_stream
@@ -680,19 +681,26 @@ def handle_get(handler, parsed) -> bool:
import time as _time
_t0 = _time.monotonic()
_debug_slow = os.environ.get("HERMES_DEBUG_SLOW", "")
sid = parse_qs(parsed.query).get("session_id", [""])[0]
query = parse_qs(parsed.query)
sid = query.get("session_id", [""])[0]
if not sid:
return j(handler, {"error": "session_id is required"}, status=400)
# ?messages=0 skips the message payload for fast session switching.
# The frontend uses this when switching conversations in the sidebar
# (only needs metadata). The full message array is loaded lazily
# via ?messages=1 when the message panel opens.
load_messages = parse_qs(parsed.query).get("messages", ["1"])[0] != "0"
load_messages = query.get("messages", ["1"])[0] != "0"
resolve_model_default = "1" if load_messages else "0"
resolve_model = query.get("resolve_model", [resolve_model_default])[0] != "0"
try:
_t1 = _time.monotonic()
s = get_session(sid, metadata_only=(not load_messages))
_t2 = _time.monotonic()
effective_model = _resolve_effective_session_model_for_display(s)
effective_model = (
_resolve_effective_session_model_for_display(s)
if resolve_model
else None
)
_t3 = _time.monotonic()
raw = s.compact() | {
"messages": s.messages if load_messages else [],
@@ -735,6 +743,8 @@ def handle_get(handler, parsed) -> bool:
"message_count": len(msgs),
"created_at": (cli_meta or {}).get("created_at", 0),
"updated_at": (cli_meta or {}).get("updated_at", 0),
"last_message_at": (cli_meta or {}).get("last_message_at")
or (cli_meta or {}).get("updated_at", 0),
"pinned": False,
"archived": False,
"project_id": None,
@@ -783,7 +793,10 @@ def handle_get(handler, parsed) -> bool:
else:
deduped_cli = []
merged = webui_sessions + deduped_cli
merged.sort(key=lambda s: s.get("updated_at", 0) or 0, reverse=True)
merged.sort(
key=lambda s: s.get("last_message_at") or s.get("updated_at", 0) or 0,
reverse=True,
)
safe_merged = []
for s in merged:
item = dict(s)
@@ -3027,8 +3040,25 @@ def _handle_create_dir(handler, body):
def _handle_workspace_add(handler, body):
path_str = body.get("path", "").strip()
name = body.get("name", "").strip()
auto_create = body.get("create", False)
if not path_str:
return bad(handler, "path is required")
# Validate the path is NOT a blocked system root BEFORE any filesystem mutation.
# This prevents creating orphan directories on rejected paths (#782 review).
candidate = Path(path_str).expanduser().resolve()
for blocked in _workspace_blocked_roots():
try:
candidate.relative_to(blocked)
return bad(handler, f"Path points to a system directory: {candidate}")
except ValueError:
pass
# Now safe to create the directory if requested
if auto_create:
try:
candidate.mkdir(parents=True, exist_ok=True)
except (OSError, PermissionError) as e:
return bad(handler, f"Could not create directory: {_sanitize_error(e)}")
# Full validation (exists, is_dir) — should pass now that dir exists
try:
p = validate_workspace_to_add(path_str)
except ValueError as e:
+232 -75
View File
@@ -25,6 +25,7 @@ from api.config import (
resolve_model_provider,
)
from api.helpers import redact_session_data
from api.metering import meter
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
@@ -292,9 +293,71 @@ def _aux_title_timeout(default: float = 15.0) -> float:
return default
def _title_completion_budget(provider: str = '', model: str = '', base_url: str = '') -> int:
if _is_minimax_route(provider, model, base_url):
return 384
return 160
# Title generation is a small auxiliary task, but reasoning models may
# spend a surprising amount of the completion budget before emitting final
# content. Keep the budget high enough for MiniMax/Kimi-style reasoning
# responses without making title generation depend on provider-specific
# one-off branches.
return 512
def _title_retry_completion_budget(provider: str = '', model: str = '', base_url: str = '') -> int:
return max(1024, _title_completion_budget(provider, model, base_url) * 2)
def _title_retry_status(status: str) -> bool:
return status in {
'llm_length',
'llm_length_aux',
'llm_empty_reasoning',
'llm_empty_reasoning_aux',
}
def _safe_obj_value(obj, key: str):
if obj is None:
return None
if isinstance(obj, dict):
return obj.get(key)
value = getattr(obj, key, None)
# Missing MagicMock attrs stringify as mock reprs and look truthy. Treat
# them as absent so tests model real provider objects accurately.
if value.__class__.__module__.startswith('unittest.mock'):
return None
return value
def _safe_text_value(value) -> str:
if value is None:
return ''
if value.__class__.__module__.startswith('unittest.mock'):
return ''
return str(value or '').strip()
def _extract_title_response(resp, *, aux: bool = False) -> tuple[str, str]:
"""Return (content, empty_status) from an OpenAI-compatible response."""
suffix = '_aux' if aux else ''
try:
choices = _safe_obj_value(resp, 'choices') or []
choice = choices[0] if choices else None
message = _safe_obj_value(choice, 'message')
content = _safe_text_value(_safe_obj_value(message, 'content'))
if content:
return content, ''
finish_reason = _safe_text_value(_safe_obj_value(choice, 'finish_reason')).lower()
reasoning = (
_safe_text_value(_safe_obj_value(message, 'reasoning'))
or _safe_text_value(_safe_obj_value(message, 'reasoning_content'))
or _safe_text_value(_safe_obj_value(message, 'thinking'))
)
if finish_reason == 'length':
return '', f'llm_length{suffix}'
if reasoning:
return '', f'llm_empty_reasoning{suffix}'
return '', f'llm_empty{suffix}'
except Exception:
return '', f'llm_empty{suffix}'
def generate_title_raw_via_aux(
@@ -308,41 +371,43 @@ def generate_title_raw_via_aux(
if not user_text or not assistant_text:
return None, 'missing_exchange'
qa, prompts = _title_prompts(user_text, assistant_text)
max_tokens = _title_completion_budget(provider, model, base_url)
base_max_tokens = _title_completion_budget(provider, model, base_url)
reasoning_extra = {"reasoning": {"enabled": False}}
if _is_minimax_route(provider, model, base_url):
reasoning_extra["reasoning_split"] = True
try:
_timeout = _aux_title_timeout()
from agent.auxiliary_client import call_llm
last_status = 'llm_error_aux'
for idx, prompt in enumerate(prompts):
messages = [
{"role": "system", "content": prompt},
{"role": "user", "content": qa},
]
budgets = [base_max_tokens]
try:
resp = call_llm(
task='title_generation',
provider=provider or None,
model=model or None,
base_url=base_url or None,
messages=messages,
max_tokens=max_tokens,
temperature=0.2,
timeout=_timeout,
extra_body=reasoning_extra,
)
raw = ''
try:
raw = resp.choices[0].message.content or ''
except Exception:
raw = ''
raw = str(raw or '').strip()
if raw:
return raw, ('llm_aux' if idx == 0 else 'llm_aux_retry')
for budget_idx, max_tokens in enumerate(budgets):
resp = call_llm(
task='title_generation',
provider=provider or None,
model=model or None,
base_url=base_url or None,
messages=messages,
max_tokens=max_tokens,
temperature=0.2,
timeout=_timeout,
extra_body=reasoning_extra,
)
raw, empty_status = _extract_title_response(resp, aux=True)
if raw:
return raw, ('llm_aux' if idx == 0 and budget_idx == 0 else 'llm_aux_retry')
last_status = empty_status or 'llm_empty_aux'
if budget_idx == 0 and _title_retry_status(last_status):
budgets.append(_title_retry_completion_budget(provider, model, base_url))
except Exception as e:
last_status = 'llm_error_aux'
logger.debug("Aux title generation attempt %s failed: %s", idx + 1, e)
return None, 'llm_error_aux'
return None, last_status
except Exception as e:
logger.debug("Aux title generation failed: %s", e)
return None, 'llm_error_aux'
@@ -356,7 +421,7 @@ def generate_title_raw_via_agent(agent, user_text: str, assistant_text: str) ->
return None, 'missing_agent'
qa, prompts = _title_prompts(user_text, assistant_text)
max_tokens = _title_completion_budget(
base_max_tokens = _title_completion_budget(
getattr(agent, 'provider', ''),
getattr(agent, 'model', ''),
getattr(agent, 'base_url', ''),
@@ -370,57 +435,70 @@ def generate_title_raw_via_agent(agent, user_text: str, assistant_text: str) ->
{"role": "system", "content": prompt},
{"role": "user", "content": qa},
]
budgets = [base_max_tokens]
try:
raw = ""
if getattr(agent, 'api_mode', '') == 'codex_responses':
codex_kwargs = agent._build_api_kwargs(api_messages)
codex_kwargs.pop('tools', None)
if 'max_output_tokens' in codex_kwargs:
codex_kwargs['max_output_tokens'] = max_tokens
resp = agent._run_codex_stream(codex_kwargs)
assistant_message, _ = agent._normalize_codex_response(resp)
raw = (assistant_message.content or '') if assistant_message else ''
elif getattr(agent, 'api_mode', '') == 'anthropic_messages':
from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response
ant_kwargs = build_anthropic_kwargs(
model=agent.model,
messages=api_messages,
tools=None,
max_tokens=max_tokens,
reasoning_config=disabled_reasoning,
is_oauth=getattr(agent, '_is_anthropic_oauth', False),
preserve_dots=agent._anthropic_preserve_dots(),
base_url=getattr(agent, '_anthropic_base_url', None),
)
resp = agent._anthropic_messages_create(ant_kwargs)
assistant_message, _ = normalize_anthropic_response(
resp, strip_tool_prefix=getattr(agent, '_is_anthropic_oauth', False)
)
raw = (assistant_message.content or '') if assistant_message else ''
else:
api_kwargs = agent._build_api_kwargs(api_messages)
api_kwargs.pop('tools', None)
api_kwargs['temperature'] = 0.1
api_kwargs['timeout'] = 15.0
if _is_minimax_route(getattr(agent, 'provider', ''), getattr(agent, 'model', ''), getattr(agent, 'base_url', '')):
extra_body = dict(api_kwargs.get('extra_body') or {})
extra_body['reasoning_split'] = True
api_kwargs['extra_body'] = extra_body
if 'max_completion_tokens' in api_kwargs:
api_kwargs['max_completion_tokens'] = max_tokens
last_status = 'llm_empty'
for budget_idx, max_tokens in enumerate(budgets):
raw = ""
empty_status = ''
if getattr(agent, 'api_mode', '') == 'codex_responses':
codex_kwargs = agent._build_api_kwargs(api_messages)
codex_kwargs.pop('tools', None)
if 'max_output_tokens' in codex_kwargs:
codex_kwargs['max_output_tokens'] = max_tokens
resp = agent._run_codex_stream(codex_kwargs)
assistant_message, _ = agent._normalize_codex_response(resp)
raw = (assistant_message.content or '') if assistant_message else ''
if not raw:
empty_status = 'llm_empty'
elif getattr(agent, 'api_mode', '') == 'anthropic_messages':
from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response
ant_kwargs = build_anthropic_kwargs(
model=agent.model,
messages=api_messages,
tools=None,
max_tokens=max_tokens,
reasoning_config=disabled_reasoning,
is_oauth=getattr(agent, '_is_anthropic_oauth', False),
preserve_dots=agent._anthropic_preserve_dots(),
base_url=getattr(agent, '_anthropic_base_url', None),
)
resp = agent._anthropic_messages_create(ant_kwargs)
assistant_message, _ = normalize_anthropic_response(
resp, strip_tool_prefix=getattr(agent, '_is_anthropic_oauth', False)
)
raw = (assistant_message.content or '') if assistant_message else ''
if not raw:
empty_status = 'llm_empty'
else:
api_kwargs['max_tokens'] = max_tokens
resp = agent._ensure_primary_openai_client(reason='title_generation').chat.completions.create(
**api_kwargs,
)
try:
raw = resp.choices[0].message.content or ""
except Exception:
raw = ""
raw = str(raw or '').strip()
if raw:
return raw, ('llm' if idx == 0 else 'llm_retry')
api_kwargs = agent._build_api_kwargs(api_messages)
api_kwargs.pop('tools', None)
api_kwargs['temperature'] = 0.1
api_kwargs['timeout'] = 15.0
if _is_minimax_route(getattr(agent, 'provider', ''), getattr(agent, 'model', ''), getattr(agent, 'base_url', '')):
extra_body = dict(api_kwargs.get('extra_body') or {})
extra_body['reasoning_split'] = True
api_kwargs['extra_body'] = extra_body
if 'max_completion_tokens' in api_kwargs:
api_kwargs['max_completion_tokens'] = max_tokens
else:
api_kwargs['max_tokens'] = max_tokens
resp = agent._ensure_primary_openai_client(reason='title_generation').chat.completions.create(
**api_kwargs,
)
raw, empty_status = _extract_title_response(resp)
raw = str(raw or '').strip()
if raw:
return raw, ('llm' if idx == 0 and budget_idx == 0 else 'llm_retry')
last_status = empty_status or 'llm_empty'
if budget_idx == 0 and _title_retry_status(last_status):
budgets.append(_title_retry_completion_budget(
getattr(agent, 'provider', ''),
getattr(agent, 'model', ''),
getattr(agent, 'base_url', ''),
))
except Exception as e:
last_status = 'llm_error'
logger.debug(
"Agent title generation attempt %s failed: provider=%s model=%s error=%s",
idx + 1,
@@ -428,7 +506,7 @@ def generate_title_raw_via_agent(agent, user_text: str, assistant_text: str) ->
getattr(agent, 'model', None),
e,
)
return None, 'llm_error'
return None, last_status
except Exception as e:
logger.debug("Agent title generation failed: %s", e)
return None, 'llm_error'
@@ -611,6 +689,11 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text
if next_title:
logger.debug("Using local fallback for session title generation")
source = 'fallback'
fallback_reason = (
f'local_summary:{llm_status}'
if source == 'fallback' and llm_status
else 'local_summary'
)
wrote_title = False
effective_title = current
if next_title:
@@ -638,7 +721,7 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text
if wrote_title:
if source == 'fallback':
_put_title_status(put_event, session_id, source, 'local_summary', effective_title, raw_preview)
_put_title_status(put_event, session_id, source, fallback_reason, effective_title, raw_preview)
else:
_put_title_status(put_event, session_id, source, llm_status, effective_title, raw_preview)
put_event('title', {'session_id': session_id, 'title': effective_title})
@@ -919,6 +1002,28 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
CANCEL_FLAGS[stream_id] = cancel_event
STREAM_PARTIAL_TEXT[stream_id] = '' # start accumulating partial text (#893)
# Register this stream with the global streaming meter
meter().begin_session(stream_id)
# Metering ticker — emits a metering event at 1 Hz while sessions are active.
# When get_interval() returns >= 10.0 (no active sessions), the ticker exits
# so no idle readings are emitted and the SSE consumer sees nothing.
_metering_stop = threading.Event()
def _metering_ticker():
while True:
interval = meter().get_interval()
if interval >= 10.0:
break # nothing active — stop the ticker
if _metering_stop.wait(interval):
break # stream was cancelled or ended — exit
stats = meter().get_stats()
stats['session_id'] = stream_id
put('metering', stats)
_metering_thread = threading.Thread(target=_metering_ticker, daemon=True)
_metering_thread.start()
def put(event, data):
# If cancelled, drop all further events except the cancel event itself
if cancel_event.is_set() and event not in ('cancel', 'error'):
@@ -1061,6 +1166,19 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
# Throttle: emit metering events at most every 100 ms so the TPS label
# feels live during fast token streams without flooding the SSE channel.
_metering_last_emit = [time.monotonic() - 1] # fire immediately on first token
def _emit_metering():
now = time.monotonic()
if now - _metering_last_emit[0] < 0.1:
return
_metering_last_emit[0] = now
stats = meter().get_stats()
stats['session_id'] = stream_id
put('metering', stats)
def on_token(text):
nonlocal _token_sent
if text is None:
@@ -1070,6 +1188,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if stream_id in STREAM_PARTIAL_TEXT:
STREAM_PARTIAL_TEXT[stream_id] += str(text)
put('token', {'text': text})
# Update global throughput meter
meter().record_token(stream_id, len(STREAM_PARTIAL_TEXT[stream_id]))
_emit_metering()
def on_reasoning(text):
nonlocal _reasoning_text
@@ -1077,6 +1198,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
return
_reasoning_text += str(text)
put('reasoning', {'text': str(text)})
# Track reasoning tokens in the meter so TPS reflects all AI output
meter().record_reasoning(stream_id, len(_reasoning_text))
_emit_metering()
# Pre-initialise the activity counter here so on_tool (which
# closes over it) never captures an unbound name even if this
@@ -1084,6 +1208,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
_checkpoint_activity = [0]
def on_tool(*cb_args, **cb_kwargs):
nonlocal _reasoning_text
event_type = None
name = None
preview = None
@@ -1103,7 +1228,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if event_type in ('reasoning.available', '_thinking'):
reason_text = preview if event_type == 'reasoning.available' else name
if reason_text:
_reasoning_text += str(reason_text)
put('reasoning', {'text': str(reason_text)})
meter().record_reasoning(stream_id, len(_reasoning_text))
_emit_metering()
return
args_snap = {}
@@ -1623,6 +1751,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
# (reasoning trace already attached + saved above, before s.save())
raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}
put('done', {'session': redact_session_data(raw_session), 'usage': usage})
# Emit metering stats for the header TPS label
meter_stats = meter().get_stats()
meter_stats['session_id'] = session_id
put('metering', meter_stats)
if _should_bg_title and _u0 and _a0:
threading.Thread(
target=_run_background_title_update,
@@ -1635,6 +1767,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
# activeSid = original session_id so they must match for stream_end to close.
put('stream_end', {'session_id': session_id})
finally:
# Stop the live metering ticker
_metering_stop.set()
# Unregister the gateway approval callback and unblock any threads
# still waiting on approval (e.g. stream cancelled mid-approval).
if _approval_registered and _unreg_notify is not None:
@@ -1660,6 +1794,13 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
except Exception as e:
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
err_str = str(e)
# Sanitize HTML from provider error responses — some providers return
# full HTML pages (e.g. nginx "404 page not found") instead of JSON errors.
# Strip HTML tags to avoid rendering raw markup in the chat message.
_stripped = re.sub(r'<[^>]+>', ' ', err_str)
_stripped = re.sub(r'\s+', ' ', _stripped).strip()
if _stripped != err_str:
err_str = _stripped
_exc_lower = err_str.lower()
# Classify before saving so the error message can be persisted to the session.
# Check quota exhaustion first — OpenAI billing 429s use insufficient_quota which
@@ -1683,6 +1824,16 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
or 'invalid api key' in _exc_lower
or 'no cookie auth credentials' in _exc_lower
)
_exc_is_not_found = (
'404' in err_str
or 'not found' in _exc_lower
or 'does not exist' in _exc_lower
or 'model not found' in _exc_lower
or 'model_not_found' in _exc_lower
or 'invalid model' in _exc_lower
or 'does not match any known model' in _exc_lower
or 'unknown model' in _exc_lower
)
if _exc_is_quota:
_exc_label, _exc_type, _exc_hint = (
'Out of credits', 'quota_exhausted',
@@ -1699,6 +1850,12 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
'The selected model may not be supported by your configured provider. '
'Run `hermes model` in your terminal to switch providers, then restart the WebUI.',
)
elif _exc_is_not_found:
_exc_label, _exc_type, _exc_hint = (
'Model not found', 'model_not_found',
'The selected model was not found by the provider. '
'Check the model ID in Settings or run `hermes model` to verify it exists for your provider.',
)
else:
_exc_label, _exc_type, _exc_hint = 'Error', 'error', ''
if s is not None:
+15 -10
View File
@@ -793,6 +793,7 @@ function applyBotName(){
window._showThinking=s.show_thinking!==false;
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
window._botName=s.bot_name||'Hermes';
if(s.default_model) window._defaultModel=s.default_model;
// Persist default workspace so the blank new-chat page can show it
// and workspace actions (New file/folder) work before the first session (#804).
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
@@ -840,16 +841,20 @@ function applyBotName(){
// Update profile chip label immediately
const profileLabel=$('profileChipLabel');
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
// Fetch available models from server and populate dropdown dynamically
await populateModelDropdown();
// Restore last-used model preference
const savedModel=localStorage.getItem('hermes-webui-model');
if(savedModel && $('modelSelect')){
$('modelSelect').value=savedModel;
// If the value didn't take (model not in list), clear the bad pref
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
else if(typeof syncModelChip==='function') syncModelChip();
}
// Fetch available models without blocking session restore. The static HTML
// options are enough for first paint; the dynamic provider list can settle
// after the saved session is visible.
const _modelDropdownReady=populateModelDropdown().then(()=>{
const savedModel=localStorage.getItem('hermes-webui-model');
if(savedModel && $('modelSelect')){
$('modelSelect').value=savedModel;
// If the value didn't take (model not in list), clear the bad pref
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
else if(typeof syncModelChip==='function') syncModelChip();
}
if(S.session) syncTopbar();
}).catch(()=>{});
window._modelDropdownReady=_modelDropdownReady;
// Pre-load workspace list so sidebar name is correct from first render
await loadWorkspaceList();
await loadOnboardingWizard();
+35 -3
View File
@@ -56,6 +56,7 @@ const LOCALES = {
model_unavailable_title: 'This model is no longer in your current provider list',
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
provider_mismatch_label: 'Provider mismatch',
model_not_found_label: 'Model not found',
model_custom_label: 'Custom model ID',
model_custom_placeholder: 'e.g. openai/gpt-5.4',
model_search_placeholder: 'Search models…',
@@ -196,6 +197,10 @@ const LOCALES = {
new_folder_prompt: 'New folder name:',
folder_created: 'Created folder ',
folder_create_failed: 'Create folder failed: ',
workspace_auto_create_folder: 'Create folder if it doesn\'t exist',
folder_add_as_space_btn: 'Add as Space',
folder_add_as_space_msg: 'Add this folder as a new space in your workspace list?',
folder_add_as_space_title: 'Add as Space?',
remove_title: 'Remove',
empty_dir: '(empty)',
upload_failed: 'Upload failed: ',
@@ -458,6 +463,7 @@ const LOCALES = {
cron_status_paused: 'paused',
cron_status_error: 'error',
cron_status_active: 'active',
cron_status_running: 'running\u2026',
cron_next: 'Next',
cron_last: 'Last',
cron_run_now: 'Run now',
@@ -605,9 +611,6 @@ const LOCALES = {
profile_api_key_label: 'API key',
},
cmd_status: '\u986f\u793a\u6703\u8a71\u8cc7\u8a0a',
memory_saved: '\u8a18\u61b6\u5df2\u5132\u5b58',
profile_delete_title: '\u522a\u9664\u6b64\u8a2d\u5b9a\u6a94',
ru: {
_lang: 'ru',
_label: 'Русский',
@@ -652,6 +655,7 @@ const LOCALES = {
provider_mismatch_warning: (m, p) =>
`"${m}" может не работать с вашим настроенным провайдером (${p}). Всё равно отправить или запустите \`hermes model\` в терминале, чтобы переключиться.`,
provider_mismatch_label: 'Несовпадение провайдера',
model_not_found_label: 'Модель не найдена',
model_custom_label: 'Пользовательский ID модели',
model_custom_placeholder: 'например, openai/gpt-5.4',
cmd_help: 'Показать доступные команды',
@@ -745,6 +749,10 @@ const LOCALES = {
new_folder_prompt: 'Имя новой папки:',
folder_created: 'Папка создана ',
folder_create_failed: 'Не удалось создать папку: ',
workspace_auto_create_folder: 'Создать папку, если она не существует',
folder_add_as_space_btn: 'Добавить',
folder_add_as_space_msg: 'Добавить эту папку как новое пространство?',
folder_add_as_space_title: 'Добавить как пространство?',
remove_title: 'Удаление',
empty_dir: '(пусто)',
upload_failed: 'Не удалось загрузить: ',
@@ -975,6 +983,7 @@ const LOCALES = {
cron_status_paused: 'на паузе',
cron_status_error: 'ошибка',
cron_status_active: 'активно',
cron_status_running: 'выполняется\u2026',
cron_next: 'Следующий',
cron_last: 'Последний',
cron_run_now: 'Запустить сейчас',
@@ -1233,6 +1242,7 @@ const LOCALES = {
model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores',
provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`,
provider_mismatch_label: 'Proveedor incompatible',
model_not_found_label: 'Modelo no encontrado',
model_custom_label: 'ID de modelo personalizado',
model_custom_placeholder: 'p. ej. openai/gpt-5.4',
model_search_placeholder: 'Buscar modelos…',
@@ -1309,6 +1319,10 @@ const LOCALES = {
new_folder_prompt: 'Nombre de la carpeta nueva:',
folder_created: 'Carpeta creada ',
folder_create_failed: 'Error al crear la carpeta: ',
workspace_auto_create_folder: 'Crear carpeta si no existe',
folder_add_as_space_btn: 'Añadir como espacio',
folder_add_as_space_msg: '¿Añadir esta carpeta como un nuevo espacio?',
folder_add_as_space_title: '¿Añadir como espacio?',
remove_title: 'Quitar',
empty_dir: '(vacío)',
upload_failed: 'Error al subir: ',
@@ -1532,6 +1546,7 @@ const LOCALES = {
cron_status_paused: 'paused',
cron_status_error: 'error',
cron_status_active: 'active',
cron_status_running: 'running\u2026',
cron_next: 'Next',
cron_last: 'Last',
cron_run_now: 'Run now',
@@ -1773,6 +1788,7 @@ const LOCALES = {
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`,
provider_mismatch_label: 'Provider-Konflikt',
model_not_found_label: 'Modell nicht gefunden',
// commands.js
cmd_help: 'Verfügbare Befehle auflisten',
cmd_clear: 'Konversationsverlauf löschen',
@@ -1852,6 +1868,10 @@ const LOCALES = {
new_folder_prompt: 'Neuer Ordnername:',
folder_created: 'Ordner erstellt ',
folder_create_failed: 'Ordner erstellen fehlgeschlagen: ',
workspace_auto_create_folder: 'Ordner erstellen, falls nicht vorhanden',
folder_add_as_space_btn: 'Als Bereich hinzufügen',
folder_add_as_space_msg: 'Diesen Ordner als neuen Bereich zur Liste hinzufügen?',
folder_add_as_space_title: 'Als Bereich hinzufügen?',
remove_title: 'Entfernen',
empty_dir: '(leer)',
upload_failed: 'Upload fehlgeschlagen: ',
@@ -2097,6 +2117,7 @@ const LOCALES = {
model_unavailable_title: '\u8fd9\u4e2a\u6a21\u578b\u5df2\u7ecf\u4e0d\u5728\u5f53\u524d provider \u5217\u8868\u4e2d',
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u65e0\u6cd5\u5728\u5f53\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u5546 (${p}) \u4e0b\u5de5\u4f5c\u3002\u76f4\u63a5\u53d1\u9001\uff0c\u6216\u5728\u7ec8\u7aef\u8fd0\u884c \`hermes model\` \u5207\u6362\u3002`,
provider_mismatch_label: '\u63d0\u4f9b\u5546\u4e0d\u5339\u914d',
model_not_found_label: '\u672a\u627e\u5230\u6a21\u578b',
model_custom_label: '\u81ea\u5b9a\u4e49\u6a21\u578b ID',
model_custom_placeholder: '\u4f8b\u5982 openai/gpt-5.4',
model_search_placeholder: '\u641c\u7d22\u6a21\u578b\u2026',
@@ -2181,6 +2202,10 @@ const LOCALES = {
new_folder_prompt: '\u65b0\u6587\u4ef6\u5939\u540d\u79f0\uff1a',
folder_created: '\u5df2\u521b\u5efa\u6587\u4ef6\u5939 ',
folder_create_failed: '\u521b\u5efa\u6587\u4ef6\u5939\u5931\u8d25\uff1a',
workspace_auto_create_folder: '\u5982\u679c\u6587\u4ef6\u5939\u4e0d\u5b58\u5728\u5219\u521b\u5efa',
folder_add_as_space_btn: '\u6dfb\u52a0\u4e3a\u5de5\u4f5c\u533a',
folder_add_as_space_msg: '\u662f\u5426\u5c06\u6b64\u6587\u4ef6\u5939\u6dfb\u52a0\u4e3a\u65b0\u7684\u5de5\u4f5c\u533a\uff1f',
folder_add_as_space_title: '\u6dfb\u52a0\u4e3a\u5de5\u4f5c\u533a\uff1f',
remove_title: '\u79fb\u9664',
empty_dir: '(\u7a7a)',
upload_failed: '\u4e0a\u4f20\u5931\u8d25\uff1a',
@@ -2394,6 +2419,7 @@ const LOCALES = {
cron_status_paused: '暂停',
cron_status_error: '错误',
cron_status_active: '运行中',
cron_status_running: '执行中\u2026',
cron_next: '下次',
cron_last: '上次',
cron_run_now: '立即运行',
@@ -2635,6 +2661,7 @@ const LOCALES = {
model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d',
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`,
provider_mismatch_label: '\u63d0\u4f9b\u8005\u4e0d\u76f8\u7b26',
model_not_found_label: '\u672a\u627e\u5230\u6a21\u578b',
// commands.js
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
cmd_clear: '\u6e05\u7a7a\u7576\u524d\u5c0d\u8a71\u8a0a\u606f',
@@ -2707,6 +2734,10 @@ const LOCALES = {
new_folder_prompt: '\u65b0\u6587\u4ef6\u593e\u540d\u7a31\uff1a',
folder_created: '\u5df2\u5275\u5efa\u6587\u4ef6\u593e ',
folder_create_failed: '\u5275\u5efa\u6587\u4ef6\u593e\u5931\u6557\uff1a',
workspace_auto_create_folder: '\u8cc7\u6599\u593e\u4e0d\u5b58\u5728\u6642\u5247\u5efa\u7acb',
folder_add_as_space_btn: '\u65b0\u589e\u70ba\u5de5\u4f5c\u5340',
folder_add_as_space_msg: '\u662f\u5426\u5c07\u6b64\u8cc7\u6599\u593e\u65b0\u589e\u70ba\u5de5\u4f5c\u5340\uff1f',
folder_add_as_space_title: '\u65b0\u589e\u70ba\u5de5\u4f5c\u5340\uff1f',
remove_title: '\u79fb\u9664',
empty_dir: '(空)',
upload_failed: '上傳失敗:',
@@ -3125,6 +3156,7 @@ const LOCALES = {
cron_schedule_required: '\u9700\u8981\u6392\u7a0b',
cron_schedule_required_example: '\u9700\u8981\u6392\u7a0b\uff08\u4f8b\u5982 "0 9 * * *" \u6216 "every 1h"\uff09',
cron_status_active: '\u6d3b\u8e8d\u4e2d',
cron_status_running: '\u57f7\u884c\u4e2d\u2026',
cron_status_error: '\u932f\u8aa4',
cron_status_off: '\u672a\u555f\u7528',
cron_status_paused: '\u5df2\u66ab\u505c',
+2 -1
View File
@@ -69,6 +69,7 @@
</span>
<span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>
<span class="app-titlebar-sub" id="appTitlebarSub" hidden></span>
<div class="tps-chip" id="tpsStat" title="Tokens per second / minute">0.0 t/s · 0.0 high</div>
</div>
<div class="app-titlebar-spacer" aria-hidden="true"></div>
</header>
@@ -456,7 +457,7 @@
<div class="main-view-actions">
<button id="btnRunTaskDetail" class="panel-head-btn" title="Run now" data-i18n-title="cron_run_now" onclick="runCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
<button id="btnPauseTaskDetail" class="panel-head-btn" title="Pause" data-i18n-title="cron_pause" onclick="pauseCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg></button>
<button id="btnResumeTaskDetail" class="panel-head-btn" title="Resume" data-i18n-title="cron_resume" onclick="resumeCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
<button id="btnResumeTaskDetail" class="panel-head-btn" title="Resume" data-i18n-title="cron_resume" onclick="resumeCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/><line x1="22" y1="4" x2="22" y2="20"/></svg></button>
<button id="btnEditTaskDetail" class="panel-head-btn" title="Edit" data-i18n-title="edit" onclick="editCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
<button id="btnDeleteTaskDetail" class="panel-head-btn" title="Delete" data-i18n-title="delete_title" onclick="deleteCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
<button id="btnCancelTaskDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelCronForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
+17 -1
View File
@@ -77,6 +77,7 @@ async function send(){
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]});
}
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
startApprovalPolling(activeSid);
startClarifyPolling(activeSid);
S.activeStreamId = null; // will be set after stream starts
@@ -754,6 +755,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}catch(err){}
});
source.addEventListener('metering',e=>{
// TPS + HIGH/LOW stats for the header chip — emitted at 1 Hz during a stream,
// silenced entirely when no sessions are active (ticker exits when idle).
try{
const d=JSON.parse(e.data||'{}');
const el=$('tpsStat');
if(!el) return;
const tps=typeof d.tps==='number'?d.tps.toFixed(1):'0.0';
const high=typeof d.high==='number' && d.high>=0?d.high.toFixed(1)+' high':'—';
const low=typeof d.low==='number' && d.low>=0?d.low.toFixed(1)+' low':'';
el.textContent=`${tps} t/s · ${high}${low?' · '+low:''}`;
}catch(_){}
});
source.addEventListener('apperror',e=>{
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
@@ -775,8 +790,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const isRateLimit=d.type==='rate_limit';
const isQuotaExhausted=d.type==='quota_exhausted';
const isAuthMismatch=d.type==='auth_mismatch';
const isModelNotFound=d.type==='model_not_found';
const isNoResponse=d.type==='no_response';
const label=isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isNoResponse?'No response received':'Error';
const label=isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):isNoResponse?'No response received':'Error';
const hint=d.hint?`\n\n*${d.hint}*`:'';
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`});
}catch(_){
+50 -2
View File
@@ -257,6 +257,7 @@ function openCronDetail(id, el){
}
function _clearCronDetail(){
if (_cronRunningPoll) { clearInterval(_cronRunningPoll); _cronRunningPoll = null; }
_currentCronDetail = null;
_cronMode = 'empty';
const title = $('taskDetailTitle');
@@ -487,14 +488,55 @@ function _cronOutputSnippet(content) {
return body.slice(0, 600) || '(empty)';
}
let _cronRunningPoll = null; // timer for polling job status after trigger
async function cronRun(id) {
try {
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
showToast(t('cron_job_triggered'));
setTimeout(() => { if (_currentCronDetail && _currentCronDetail.id === id) _loadCronDetailRuns(id); }, 5000);
// Immediately show "running" state in detail if this job is selected
if (_currentCronDetail && _currentCronDetail.id === id) {
_setCronDetailStatus('running');
_startCronRunningPoll(id);
}
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
}
function _setCronDetailStatus(status) {
const badge = document.querySelector('#taskDetailBody .detail-badge');
if (!badge) return;
if (status === 'running') {
badge.className = 'detail-badge running';
badge.textContent = t('cron_status_running');
}
}
function _startCronRunningPoll(jobId) {
// Clear any existing poll
if (_cronRunningPoll) { clearInterval(_cronRunningPoll); _cronRunningPoll = null; }
let attempts = 0;
const maxAttempts = 10; // 10 * 3s = 30s max
_cronRunningPoll = setInterval(async () => {
attempts++;
if (!_currentCronDetail || _currentCronDetail.id !== jobId || attempts > maxAttempts) {
clearInterval(_cronRunningPoll);
_cronRunningPoll = null;
// Re-render detail with real status when poll ends (fallback from "running" indicator)
if (_currentCronDetail && _currentCronDetail.id === jobId) {
const refreshed = _cronList ? _cronList.find(j => j.id === jobId) : null;
if (refreshed) _renderCronDetail(refreshed);
}
return;
}
try {
await loadCrons();
// loadCrons() re-renders the detail which overwrites our "running" badge.
// Re-apply the running indicator if poll is still active.
if (_cronRunningPoll) _setCronDetailStatus('running');
} catch(e) { /* ignore */ }
}, 3000);
}
async function cronPause(id) {
try {
await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})});
@@ -1406,6 +1448,12 @@ function _renderWorkspaceForm({ name, path, isEdit }){
</div>
${pathHint}
</div>
${!isEdit?`<div class="detail-form-row">
<label class="detail-form-check">
<input type="checkbox" id="workspaceFormAutoCreate">
${esc(t('workspace_auto_create_folder')||'Create folder if it doesn\'t exist')}
</label>
</div>`:''}
<div id="workspaceFormError" class="detail-form-error" style="display:none"></div>
</form>
</div>`;
@@ -1451,7 +1499,7 @@ async function saveWorkspaceForm(){
openWorkspaceDetail(targetPath);
return;
}
const data = await api('/api/workspaces/add', { method:'POST', body: JSON.stringify({ path }) });
const data = await api('/api/workspaces/add', { method:'POST', body: JSON.stringify({ path, name, create: ($('workspaceFormAutoCreate')&&$('workspaceFormAutoCreate').checked)||false }) });
_workspaceList = data.workspaces || [];
_workspacePreFormDetail = null;
// Apply rename if a friendly name was supplied
+46 -14
View File
@@ -109,7 +109,7 @@ async function loadSession(sid){
// Guard against network/server failures to prevent a permanently stuck loading state.
let data;
try {
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0`);
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
} catch(e) {
const _msgInner = $('msgInner');
if (_msgInner) {
@@ -119,6 +119,7 @@ async function loadSession(sid){
return;
}
S.session=data.session;
S.session._modelResolutionDeferred=true;
S.lastUsage={...(data.session.last_usage||{})};
_setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0));
localStorage.setItem('hermes-webui-session',S.session.session_id);
@@ -254,6 +255,23 @@ async function loadSession(sid){
threshold_tokens: _pick(u.threshold_tokens, _s.threshold_tokens),
});
}
_resolveSessionModelForDisplaySoon(sid);
}
function _resolveSessionModelForDisplaySoon(sid){
if(!sid) return;
setTimeout(async()=>{
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=1`);
const model=data&&data.session&&data.session.model;
if(!model||!S.session||S.session.session_id!==sid) return;
S.session.model=model;
S.session._modelResolutionDeferred=false;
syncTopbar();
}catch(_){
// Keep session switching non-blocking; the next load can try again.
}
},0);
}
// Load session messages if not already present.
@@ -266,7 +284,7 @@ async function _ensureMessagesLoaded(sid) {
return;
}
// Fetch full session with messages
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1`);
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`);
const msgs = (data.session.messages || []).filter(m => m && m.role);
// Check for tool-call metadata on messages (for tool-call card rendering)
const hasMessageToolMetadata = msgs.some(m => {
@@ -282,6 +300,11 @@ async function _ensureMessagesLoaded(sid) {
}
clearLiveToolCards();
S.messages = msgs;
if(S.session&&S.session.session_id===sid){
S.session.message_count=Number(data.session.message_count || msgs.length);
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
_setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));
}
}
let _allSessions = []; // cached for search filter
@@ -621,7 +644,7 @@ function filterSessions(){
}
function _sessionTimestampMs(session) {
const raw = Number(session && (session.updated_at || session.created_at || 0));
const raw = Number(session && (session.last_message_at || session.updated_at || session.created_at || 0));
return Number.isFinite(raw) ? raw * 1000 : 0;
}
@@ -803,7 +826,7 @@ function renderSessionListFromCache(){
hdr.className='session-date-header'+(g.isPinned?' pinned':'');
const caret=document.createElement('span');
caret.className='session-date-caret';
caret.textContent='\u25B8'; // right-pointing triangle
caret.textContent='\u25BE'; // down when expanded; rotated right when collapsed
const label=document.createElement('span');
label.textContent=g.label;
hdr.appendChild(caret);hdr.appendChild(label);
@@ -818,18 +841,25 @@ function renderSessionListFromCache(){
_saveCollapsed();
};
wrapper.appendChild(hdr);
for(const s of g.items){ body.appendChild(_renderOneSession(s)); }
for(const s of g.items){ body.appendChild(_renderOneSession(s, Boolean(g.isPinned))); }
wrapper.appendChild(body);
list.appendChild(wrapper);
}
// ── Render session items (extracted for group body use) ──
// Note: declared after the groups loop but available via function hoisting.
function _renderOneSession(s){
function _renderOneSession(s, isPinnedGroup=false){
const el=document.createElement('div');
const isActive=S.session&&s.session_id===S.session.session_id;
const isStreaming=Boolean(s.is_streaming);
const isLocalStreaming=Boolean(
s.session_id
&& (
(isActive&&S.busy)
|| (typeof INFLIGHT==='object'&&INFLIGHT&&INFLIGHT[s.session_id])
)
);
const isStreaming=Boolean(s.is_streaming||isLocalStreaming);
const hasUnread=_hasUnreadForSession(s)&&!isActive;
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'');
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'')+(hasUnread?' unread':'');
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
const rawTitle=s.title||'Untitled';
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
@@ -842,23 +872,21 @@ function renderSessionListFromCache(){
sessionText.className='session-text';
const titleRow=document.createElement('div');
titleRow.className='session-title-row';
if(s.pinned){
if(s.pinned&&!isPinnedGroup){
const pinInd=document.createElement('span');
pinInd.className='session-pin-indicator';
pinInd.innerHTML=ICONS.pin;
titleRow.appendChild(pinInd);
}
const state=document.createElement('span');
state.className='session-state-indicator'+(isStreaming?' is-streaming':(hasUnread?' is-unread':''));
titleRow.appendChild(state); // always reserve slot — prevents title shift when indicator appears
const title=document.createElement('span');
title.className='session-title';
title.textContent=cleanTitle||'Untitled';
title.title='Double-click to rename';
const tsMs=_sessionTimestampMs(s);
const ts=document.createElement('span');
ts.className='session-time';
ts.textContent=_formatRelativeSessionTime(tsMs);
const hasAttentionState=isStreaming||hasUnread;
ts.className='session-time'+(hasAttentionState?' is-hidden':'');
ts.textContent=hasAttentionState?'':_formatRelativeSessionTime(tsMs);
titleRow.appendChild(title);
titleRow.appendChild(ts);
sessionText.appendChild(titleRow);
@@ -942,6 +970,10 @@ function renderSessionListFromCache(){
}
}
el.appendChild(sessionText);
const state=document.createElement('span');
state.className='session-attention-indicator session-state-indicator'+(isStreaming?' is-streaming':(hasUnread?' is-unread':''));
state.setAttribute('aria-hidden','true');
el.appendChild(state);
// Single trigger button that opens a shared dropdown menu
const actions=document.createElement('div');
actions.className='session-actions';
+32 -7
View File
@@ -207,7 +207,11 @@
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;flex-direction:column;}
.layout{display:flex;width:100%;flex:1 1 auto;min-height:0;}
.app-titlebar{display:flex;align-items:center;justify-content:space-between;height:38px;flex-shrink:0;background:var(--sidebar);border-bottom:1px solid var(--border);padding:0 12px;padding-top:env(safe-area-inset-top,0);padding-left:max(12px,env(safe-area-inset-left,0));padding-right:max(12px,env(safe-area-inset-right,0));box-sizing:content-box;font-size:12px;color:var(--muted);user-select:none;-webkit-app-region:drag;position:relative;z-index:20;}
.app-titlebar-inner{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%;flex:1 1 auto;justify-content:center;}
.app-titlebar-inner{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%;flex:1 1 auto;justify-content:space-between;}
.tps-chip{
font-size:11px;font-family:ui-monospace,'SF Mono',monospace;color:var(--muted);
white-space:nowrap;letter-spacing:.02em;flex-shrink:0;
}
.app-titlebar-icon{display:inline-flex;align-items:center;color:var(--accent);}
.app-titlebar-title{font-size:12px;font-weight:600;color:var(--text);letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60vw;}
.app-titlebar-sub{font-size:10px;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;font-family:'SF Mono',ui-monospace,monospace;white-space:nowrap;flex-shrink:0;}
@@ -230,7 +234,8 @@
.sidebar-search-icon{position:absolute;left:22px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--muted);opacity:.7;pointer-events:none;}
/* Inline session title edit */
.session-title-input{flex:1;background:var(--surface);border:1px solid var(--accent);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px var(--accent-bg-strong);font-family:inherit;}
.session-item{padding:8px 40px 8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;}
.session-item{padding:8px 86px 8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;}
.session-item.streaming,.session-item.unread{padding-right:40px;}
.session-item:hover{background:var(--hover-bg);color:var(--text);}
.session-item.active{background:var(--accent-bg);color:var(--accent);}
.session-item.streaming .session-title{color:var(--accent);}
@@ -242,6 +247,8 @@
.session-meta{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.session-item.active .session-meta{color:var(--accent-text);opacity:.8;}
.session-state-indicator{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:10px;height:10px;color:var(--accent);visibility:hidden;}
.session-attention-indicator{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:26px;height:26px;z-index:1;pointer-events:none;transition:opacity .15s ease,visibility .15s ease;}
.session-item:hover .session-attention-indicator,.session-item:focus-within .session-attention-indicator,.session-item.menu-open .session-attention-indicator{opacity:0;visibility:hidden;}
.session-state-indicator.is-streaming,.session-state-indicator.is-unread{visibility:visible;}
.session-state-indicator::before{content:"";display:block;flex-shrink:0;}
.session-state-indicator.is-streaming::before{
@@ -259,17 +266,31 @@
border-radius:50%;
background:currentColor;
}
.session-attention-indicator.is-streaming::before{
width:10px;
height:10px;
}
.session-attention-indicator.is-unread::before{
width:8px;
height:8px;
}
.session-time{
display:inline-flex;
margin-left:auto;
position:absolute;
right:10px;
top:50%;
transform:translateY(-50%);
color:var(--muted);
font-size:10px;
white-space:nowrap;
flex-shrink:0;
}
.session-time.is-hidden{display:none;}
.session-item:hover .session-time,.session-item:focus-within .session-time,.session-item.menu-open .session-time{display:none;}
/* ── Session action trigger + dropdown ── */
.session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;}
.session-item:hover .session-actions,.session-item:focus-within .session-actions,.session-item.menu-open .session-actions{opacity:1;pointer-events:auto;}
.session-item.streaming:not(:hover):not(:focus-within):not(.menu-open) .session-actions,.session-item.unread:not(:hover):not(:focus-within):not(.menu-open) .session-actions{opacity:0;pointer-events:none;}
.session-actions-trigger{width:26px;height:26px;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer;padding:0;line-height:1;display:inline-flex;align-items:center;justify-content:center;transition:background .12s,color .12s,border-color .12s;}
.session-actions-trigger:hover{background:var(--hover-bg);color:var(--text);}
.session-actions-trigger.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--text);}
@@ -294,7 +315,7 @@
.session-date-header{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 10px 4px;cursor:pointer;user-select:none;opacity:.8;transition:opacity .15s;}
.session-date-header:hover{opacity:1;}
.session-date-header.pinned{color:var(--accent);}
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;}
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;transform:rotate(0deg);}
.session-date-caret.collapsed{transform:rotate(-90deg);}
.app-dialog-overlay{position:fixed;inset:0;background:rgba(7,12,19,.62);backdrop-filter:blur(6px);z-index:1100;display:none;align-items:center;justify-content:center;padding:24px;}
.app-dialog{width:min(460px,100%);background:linear-gradient(180deg,rgba(21,31,45,.98),rgba(13,20,31,.98));border:1px solid var(--accent-bg-strong);border-radius:18px;box-shadow:0 18px 60px rgba(0,0,0,.45);padding:18px 18px 16px;color:var(--text);}
@@ -597,7 +618,7 @@
.composer-profile-chip.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);}
.composer-profile-icon,.composer-profile-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-profile-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-ws-wrap{position:relative;flex:0 1 auto;min-width:0;}
.composer-ws-wrap{position:relative;flex:0 1 auto;min-width:0;display:flex;align-items:center;gap:4px;}
.composer-workspace-group{display:inline-flex;align-items:stretch;max-width:240px;border-radius:999px;overflow:hidden;background-color:transparent;transition:background-color .15s;}
.composer-workspace-group:hover{background-color:var(--hover-bg);}
.composer-workspace-group:hover .composer-workspace-files-btn,
@@ -812,7 +833,8 @@
.ctx-tooltip{right:-4px;min-width:190px;max-width:220px;}
/* Touch targets — minimum 44px */
.icon-btn,.mic-btn{min-width:44px;min-height:44px;}
.session-item{min-height:44px;padding:10px 40px 10px 12px;}
.session-item{min-height:44px;padding:10px 86px 10px 12px;}
.session-item.streaming,.session-item.unread{padding-right:40px;}
.session-actions{opacity:1;pointer-events:auto;}
/* Empty state */
.empty-state h2{font-size:18px;}
@@ -2108,7 +2130,7 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
.main-view-header{
display:flex;align-items:center;justify-content:space-between;gap:16px;
min-height:41px;padding:8px 32px;border-bottom:1px solid var(--border);
flex-shrink:0;background:var(--bg);
flex-shrink:0;background:var(--bg);position:relative;z-index:10;
}
.main-view-title{font-size:18px;font-weight:600;letter-spacing:-.01em;color:var(--text);line-height:1.3;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.main-view-actions{display:flex;align-items:center;gap:4px;flex-shrink:0;}
@@ -2150,6 +2172,9 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.detail-badge.ok{background:rgba(100,200,100,.12);color:rgba(100,200,100,.95);border-color:rgba(100,200,100,.3);}
.detail-badge.warn{background:rgba(220,140,60,.12);color:rgba(220,140,60,.95);border-color:rgba(220,140,60,.3);}
.detail-badge.err{background:rgba(220,80,80,.12);color:rgba(220,80,80,.95);border-color:rgba(220,80,80,.3);}
.detail-badge.running{background:rgba(59,130,246,.12);color:rgba(96,165,250,.95);border-color:rgba(96,165,250,.3);}
.detail-badge.running::before{content:'';display:inline-block;width:10px;height:10px;border:2px solid rgba(96,165,250,.4);border-top-color:rgba(96,165,250,.95);border-radius:50%;margin-right:6px;vertical-align:middle;animation:cron-spinner .6s linear infinite;}
@keyframes cron-spinner{to{transform:rotate(360deg);}}
.detail-prompt{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:'SF Mono',ui-monospace,monospace;max-height:240px;overflow-y:auto;}
.detail-run-item{border-top:1px solid var(--border);padding:8px 0;}
.detail-run-item:first-child{border-top:none;}
+49 -7
View File
@@ -1428,6 +1428,7 @@ function syncTopbar(){
// If the model isn't in the current provider list, silently reset to the
// first available model so stale values don't pollute the picker (#829).
if(!applied && currentModel){
const deferModelCorrection=Boolean(S.session._modelResolutionDeferred);
// Stale session model not in the current provider catalog — reset to the
// first available model rather than injecting an "(unavailable)" option
// that visually appears under the wrong provider group (#829).
@@ -1435,13 +1436,15 @@ function syncTopbar(){
const first=modelSel&&modelSel.querySelector('optgroup > option, option');
if(first){
modelSel.value=first.value;
S.session.model=first.value;
// Persist the correction so the session doesn't re-inject on next load.
fetch(new URL('api/session/update',location.href).href,{
method:'POST',credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value})
}).catch(()=>{});
if(!deferModelCorrection){
S.session.model=first.value;
// Persist the correction so the session doesn't re-inject on next load.
fetch(new URL('api/session/update',location.href).href,{
method:'POST',credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value})
}).catch(()=>{});
}
}
}
}
@@ -2176,6 +2179,9 @@ function buildToolCard(tc){
// message column eliminates the visible "jump" users saw when renderMessages
// fired on the done event.
function appendLiveToolCard(tc){
// Guard: ignore if session was switched. Prevents stale tool events from
// a previous session's SSE stream from manipulating the new session's DOM.
if(!S.session||!S.activeStreamId) return;
let turn=$('liveAssistantTurn');
if(!turn){
appendThinking();
@@ -2463,10 +2469,22 @@ function finalizeThinkingCard(){
row.remove();
return;
}
// If the user was watching (scroll pinned = at bottom), scroll the thinking
// card back to the top so the completed response is visible underneath without
// the thinking content blocking it. If they scrolled up to read history,
// leave their scroll position intact.
if(_scrollPinned){
const body=row&&row.querySelector('.thinking-card-body');
if(body) body.scrollTop=0;
}
row.removeAttribute('id');
row.removeAttribute('data-thinking-active');
}
function appendThinking(text=''){
// Guard: ignore if session was switched during an async SSE stream.
// The old stream's reasoning events can still fire after switch;
// without this check they would pollute the new session's DOM.
if(!S.session||!S.activeStreamId) return;
$('emptyState').style.display='none';
let turn=$('liveAssistantTurn');
if(!turn){
@@ -2496,6 +2514,12 @@ function appendThinking(text=''){
row.className=(text&&String(text).trim())?'assistant-segment thinking-card-row':'assistant-segment';
row.innerHTML=_thinkingMarkup(text);
scrollIfPinned();
// Auto-scroll the thinking card body to bottom if the user is watching
// (scroll pinned). If the user scrolled up to read history, leave it alone.
if(_scrollPinned){
const body=row&&row.querySelector('.thinking-card-body');
if(body) body.scrollTop=body.scrollHeight;
}
}
function updateThinking(text=''){appendThinking(text);}
function removeThinking(){
@@ -2759,6 +2783,24 @@ async function promptNewFolder(){
await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
showToast(t('folder_created')+name.trim());
await loadDir(S.currentDir);
// Offer to add the new folder as a space (#782)
const absPath=S.session.workspace?((S.currentDir==='.'?S.session.workspace:S.session.workspace+'/'+S.currentDir)+'/'+name.trim()):null;
if(absPath){
const addAsSpace=await showConfirmDialog({
title:t('folder_add_as_space_title'),
message:t('folder_add_as_space_msg'),
confirmLabel:t('folder_add_as_space_btn'),
focusCancel:true
});
if(addAsSpace){
try{
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:absPath})});
if(typeof _workspaceList!=='undefined')_workspaceList=data.workspaces||_workspaceList||[];
if(typeof renderWorkspacesPanel==='function')renderWorkspacesPanel(_workspaceList);
showToast(t('workspace_added'));
}catch(e2){setStatus((t('error_prefix')||'Error: ')+e2.message);}
}
}
}catch(e){setStatus(t('folder_create_failed')+e.message);}
}
+73
View File
@@ -156,6 +156,79 @@ def test_gateway_sessions_appear_when_enabled():
post('/api/settings', {'show_cli_sessions': False})
def test_gateway_sessions_without_messages_are_hidden_from_sidebar():
"""Regression: empty agent session rows must not appear as broken sidebar entries."""
conn = _ensure_state_db()
empty_sid = 'gw_empty_no_messages_001'
try:
conn.execute(
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
"VALUES (?, ?, ?, ?, ?, ?)",
(empty_sid, 'cron', 'Cron Session', 'openai/gpt-5', time.time(), 0),
)
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
conn.commit()
post('/api/settings', {'show_cli_sessions': True})
data, status = get('/api/sessions')
assert status == 200
sessions = data.get('sessions', [])
assert empty_sid not in {s.get('session_id') for s in sessions}, (
"Agent sessions with no readable message rows should be filtered before "
"they reach the sidebar; otherwise clicking them fails during import."
)
finally:
try:
_remove_test_sessions(conn, empty_sid)
conn.close()
except Exception:
pass
post('/api/settings', {'show_cli_sessions': False})
def test_gateway_watcher_hides_sessions_without_messages(monkeypatch):
"""Regression: SSE watcher must use the same importable-agent filter."""
conn = _ensure_state_db()
empty_sid = 'gw_empty_watcher_001'
live_sid = 'gw_live_watcher_001'
try:
conn.execute(
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
"VALUES (?, ?, ?, ?, ?, ?)",
(empty_sid, 'cron', 'Empty Cron Session', 'openai/gpt-5', time.time(), 0),
)
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
_insert_gateway_session(
conn,
session_id=live_sid,
source='cron',
title='Live Cron Session',
message_count=0,
)
import api.gateway_watcher as gateway_watcher
monkeypatch.setattr(gateway_watcher, '_get_state_db_path', _get_state_db_path)
sessions = gateway_watcher._get_agent_sessions_from_db()
ids = {s.get('session_id') for s in sessions}
live = next((s for s in sessions if s.get('session_id') == live_sid), None)
assert empty_sid not in ids
assert live is not None
assert live.get('message_count') == 2, (
"Watcher should fall back to actual message rows when stored "
"message_count is zero, matching the sidebar route."
)
finally:
try:
_remove_test_sessions(conn, empty_sid, live_sid)
conn.close()
except Exception:
pass
def test_gateway_sessions_excluded_when_disabled():
"""Gateway sessions are NOT returned when show_cli_sessions is off."""
conn = _ensure_state_db()
+198
View File
@@ -0,0 +1,198 @@
"""
Tests for issue #1014 — model-not-found error classification.
Covers:
1. streaming.py: 404/model-not-found errors detected and classified as 'model_not_found'
2. streaming.py: HTML tags stripped from provider error messages before classification
3. static/messages.js: apperror handler has model_not_found branch
4. static/i18n.js: model_not_found_label key present in all locales
5. streaming.py: model_not_found checked after auth but before generic error
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
def _read(rel_path: str) -> str:
return (REPO_ROOT / rel_path).read_text(encoding="utf-8")
# ── 1. streaming.py: model-not-found error detection ─────────────────────────
class TestStreamingModelNotFoundDetection:
"""streaming.py must classify 404/model-not-found errors as model_not_found."""
def test_model_not_found_type_defined_in_streaming(self):
"""'model_not_found' type must be emitted for 404 errors."""
src = _read("api/streaming.py")
assert "model_not_found" in src, (
"model_not_found type not found in streaming.py — "
"404 errors will not be surfaced with a helpful message"
)
def test_is_not_found_flag_defined(self):
"""_exc_is_not_found variable must exist in the exception handler."""
src = _read("api/streaming.py")
assert "_exc_is_not_found" in src, (
"_exc_is_not_found flag not found in streaming.py"
)
def test_not_found_detects_404(self):
"""'404' must be part of the model-not-found detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
assert idx != -1, "_exc_is_not_found not found"
block = src[idx:idx + 600]
assert "'404'" in block or '"404"' in block, (
"'404' not in model-not-found detection block"
)
def test_not_found_detects_not_found_string(self):
"""'not found' must be part of the detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
block = src[idx:idx + 600]
assert "not found" in block.lower(), (
"'not found' not in model-not-found detection block"
)
def test_not_found_detects_does_not_exist(self):
"""'does not exist' must be part of the detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
block = src[idx:idx + 600]
assert "does not exist" in block.lower(), (
"'does not exist' not in model-not-found detection block"
)
def test_not_found_detects_invalid_model(self):
"""'invalid model' must be part of the detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
block = src[idx:idx + 600]
assert "invalid model" in block.lower(), (
"'invalid model' not in model-not-found detection block"
)
def test_not_found_hint_mentions_settings(self):
"""The model_not_found hint must mention Settings or hermes model."""
src = _read("api/streaming.py")
idx = src.find("model_not_found")
block = src[idx:idx + 500]
assert "Settings" in block or "hermes model" in block, (
"model_not_found hint must mention Settings or hermes model command"
)
def test_not_found_check_order_after_auth(self):
"""model_not_found must be checked after auth_mismatch (auth first)."""
src = _read("api/streaming.py")
auth_idx = src.find("elif _exc_is_auth")
nf_idx = src.find("elif _exc_is_not_found")
assert auth_idx != -1, "_exc_is_auth not found"
assert nf_idx != -1, "_exc_is_not_found not found"
assert auth_idx < nf_idx, (
"auth_mismatch should be checked before model_not_found — "
"auth errors must not be mistaken for not-found errors"
)
# ── 2. streaming.py: HTML sanitization ───────────────────────────────────────
class TestStreamingHtmlSanitization:
"""Provider error messages containing HTML must be stripped."""
def test_html_strip_before_classification(self):
"""HTML tags must be stripped before error classification."""
src = _read("api/streaming.py")
# Find the HTML sanitization block in the exception handler
# It should appear before _exc_lower = err_str.lower()
sanitize_idx = src.find("re.sub(r'<[^>]+>'")
exc_lower_idx = src.find("_exc_lower = err_str.lower()")
assert sanitize_idx != -1, (
"HTML tag stripping (re.sub) not found in streaming.py exception handler"
)
assert exc_lower_idx != -1, "_exc_lower not found"
assert sanitize_idx < exc_lower_idx, (
"HTML sanitization must happen before error classification"
)
def test_whitespace_normalization(self):
"""Stripped HTML must have whitespace collapsed."""
src = _read("api/streaming.py")
sanitize_idx = src.find("re.sub(r'<[^>]+>'")
block = src[sanitize_idx:sanitize_idx + 300]
assert r"\s+" in block, (
"Whitespace normalization (\\s+) not found after HTML strip"
)
# ── 3. static/messages.js: apperror handler ──────────────────────────────────
class TestApperrorModelNotFound:
"""messages.js apperror handler must handle model_not_found type."""
def test_model_not_found_type_handled(self):
"""apperror handler must check for type='model_not_found'."""
src = _read("static/messages.js")
assert "model_not_found" in src, (
"model_not_found type not handled in messages.js apperror handler"
)
def test_model_not_found_label(self):
"""'Model not found' label must appear in the error handling."""
src = _read("static/messages.js")
assert "Model not found" in src, (
"'Model not found' label not found in messages.js"
)
def test_is_model_not_found_variable(self):
"""isModelNotFound variable must be defined."""
src = _read("static/messages.js")
assert "isModelNotFound" in src, (
"isModelNotFound variable not found in messages.js apperror handler"
)
# ── 4. static/i18n.js: all locales ───────────────────────────────────────────
class TestI18nModelNotFound:
"""All locales must have model_not_found_label."""
REQUIRED_KEY = "model_not_found_label"
def _locale_names(self, src: str) -> list:
pattern = re.compile(
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
re.MULTILINE,
)
names = []
for match in pattern.finditer(src):
names.append(match.group("quoted") or match.group("plain"))
return names
def _count_key(self, src: str, key: str) -> int:
return len(re.findall(r'\b' + re.escape(key) + r'\b', src))
def test_all_locales_have_model_not_found_label(self):
"""model_not_found_label must appear in all locales."""
src = _read("static/i18n.js")
locale_count = len(self._locale_names(src))
count = self._count_key(src, self.REQUIRED_KEY)
assert count >= locale_count, (
f"model_not_found_label found {count} times, expected >= {locale_count} "
f"(one per locale)"
)
def test_english_label_is_plain_string(self):
"""English model_not_found_label must be a plain string, not a function."""
src = _read("static/i18n.js")
en_start = src.find("\n en: {")
es_start = src.find("\n es: {")
en_block = src[en_start:es_start]
assert self.REQUIRED_KEY in en_block, "Key not in en block"
idx = en_block.find(self.REQUIRED_KEY)
line = en_block[idx:idx + 200]
assert "=>" not in line, (
"model_not_found_label should be a plain string, not an arrow function"
)
+8 -5
View File
@@ -14,7 +14,10 @@ import pathlib
import re
MODELS_PY = pathlib.Path(__file__).parent.parent / 'api' / 'models.py'
AGENT_SESSIONS_PY = pathlib.Path(__file__).parent.parent / 'api' / 'agent_sessions.py'
src = MODELS_PY.read_text(encoding='utf-8')
agent_src = AGENT_SESSIONS_PY.read_text(encoding='utf-8')
combined_src = src + "\n" + agent_src
class TestCliSessionsErrorSurface:
@@ -22,16 +25,16 @@ class TestCliSessionsErrorSurface:
def test_schema_introspection_present(self):
"""The function must check for the 'source' column before querying."""
assert "PRAGMA table_info(sessions)" in src
assert "PRAGMA table_info(sessions)" in combined_src
def test_missing_source_column_logs_warning(self):
"""If 'source' column is absent, a warning is logged."""
# The warning message must mention the missing column and how to fix it
assert "no 'source' column" in src or "has no 'source' column" in src
assert "no 'source' column" in combined_src or "has no 'source' column" in combined_src
def test_missing_source_column_suggests_upgrade(self):
"""Warning message must suggest upgrading hermes-agent."""
assert "Upgrade hermes-agent" in src or "upgrade hermes-agent" in src.lower()
assert "Upgrade hermes-agent" in combined_src or "upgrade hermes-agent" in combined_src.lower()
def test_exception_path_logs_warning(self):
"""The except clause must call logger.warning, not silently pass."""
@@ -67,8 +70,8 @@ class TestCliSessionsErrorSurface:
def test_source_column_check_before_sql_query(self):
"""Schema check must happen before the main SQL SELECT."""
pragma_pos = src.find("PRAGMA table_info(sessions)")
select_pos = src.find("SELECT s.id, s.title, s.model")
pragma_pos = agent_src.find("PRAGMA table_info(sessions)")
select_pos = agent_src.find("SELECT s.id, s.title, s.model")
assert pragma_pos != -1, "PRAGMA check not found"
assert select_pos != -1, "SELECT query not found"
assert pragma_pos < select_pos, \
+81 -15
View File
@@ -11,6 +11,10 @@ def test_pinned_indicator_renders_inside_title_row():
title_row_idx = SESSIONS_JS.find("titleRow.className='session-title-row';")
assert title_row_idx != -1, "session title row construction not found"
assert "body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))" in SESSIONS_JS
assert "function _renderOneSession(s, isPinnedGroup=false)" in SESSIONS_JS
assert "if(s.pinned&&!isPinnedGroup){" in SESSIONS_JS
pin_idx = SESSIONS_JS.find("pinInd.className='session-pin-indicator';", title_row_idx)
assert pin_idx != -1, "pinned indicator creation not found after title row"
@@ -32,26 +36,88 @@ def test_pinned_indicator_uses_fixed_indicator_box():
assert "justify-content:center;" in css_block, "pin indicator should center the star inside its box"
def test_state_indicator_always_appended_to_prevent_layout_shift():
"""State span is always added to the DOM (visibility:hidden when inactive) to prevent
titles shifting left/right when the spinner or unread dot appears/disappears."""
def test_state_indicator_uses_right_actions_slot_to_prevent_title_shift():
"""State span reuses the right-side action slot so the title start position
does not shift when the spinner or unread dot appears/disappears."""
title_row_idx = SESSIONS_JS.find("titleRow.className='session-title-row';")
assert title_row_idx != -1, "title row construction not found"
# state span must be appended unconditionally (no surrounding if-check)
append_idx = SESSIONS_JS.find("titleRow.appendChild(state);", title_row_idx)
assert append_idx != -1, "state span must always be appended to titleRow"
# Verify CSS uses visibility:hidden to reserve the slot
assert "session-state-indicator{" in STYLE_CSS, "session-state-indicator CSS rule missing"
base_block_start = STYLE_CSS.find("session-state-indicator{")
base_block_end = STYLE_CSS.find("}", base_block_start)
base_block = STYLE_CSS[base_block_start:base_block_end]
assert "visibility:hidden;" in base_block, (
"session-state-indicator should default to visibility:hidden so it reserves slot "
"without being visible — prevents title layout shift on state changes"
title_row_append_idx = SESSIONS_JS.find("titleRow.appendChild(state);", title_row_idx)
assert title_row_append_idx == -1, (
"state indicator should not be inserted before the title; it should reuse "
"the right-side actions slot to avoid title shift"
)
state_idx = SESSIONS_JS.find("state.className='session-attention-indicator session-state-indicator'")
assert state_idx != -1, "right-side attention indicator creation not found"
append_to_row_idx = SESSIONS_JS.find("el.appendChild(state);", state_idx)
assert append_to_row_idx != -1, "state indicator should be appended to the outer row"
actions_idx = SESSIONS_JS.find("actions.className='session-actions';", append_to_row_idx)
assert actions_idx != -1, "session actions should still be appended after attention indicator"
assert ".session-attention-indicator{" in STYLE_CSS, "attention indicator CSS rule missing"
css_block = STYLE_CSS[
STYLE_CSS.find(".session-attention-indicator{"):
STYLE_CSS.find(".session-item:hover .session-attention-indicator")
]
assert "position:absolute;" in css_block, "attention indicator should be positioned in the row action slot"
assert "right:6px;" in css_block, "attention indicator should align with the actions trigger"
assert "width:26px;" in css_block, "attention indicator should use the same width as the actions trigger"
assert "height:26px;" in css_block, "attention indicator should use the same height as the actions trigger"
assert ".session-attention-indicator.is-streaming::before{" in STYLE_CSS
inner_spinner_block = STYLE_CSS[
STYLE_CSS.find(".session-attention-indicator.is-streaming::before{"):
STYLE_CSS.find(".session-attention-indicator.is-unread::before{")
]
assert "width:10px;" in inner_spinner_block, "spinner glyph should stay 10px inside the 26px action slot"
assert "height:10px;" in inner_spinner_block, "spinner glyph should stay 10px inside the 26px action slot"
hover_rule = ".session-item:hover .session-attention-indicator"
assert hover_rule in STYLE_CSS, "hover rule should hide attention indicator when actions appear"
def test_timestamp_hidden_when_attention_state_is_present():
assert "+(hasUnread?' unread':'')" in SESSIONS_JS
assert "const hasAttentionState=isStreaming||hasUnread;" in SESSIONS_JS
assert "ts.className='session-time'+(hasAttentionState?' is-hidden':'');" in SESSIONS_JS
assert "ts.textContent=hasAttentionState?'':_formatRelativeSessionTime(tsMs);" in SESSIONS_JS
assert ".session-time.is-hidden{display:none;}" in STYLE_CSS
assert ".session-item{padding:8px 86px 8px 8px;" in STYLE_CSS
assert ".session-item.streaming,.session-item.unread{padding-right:40px;}" in STYLE_CSS
assert ".session-item{min-height:44px;padding:10px 86px 10px 12px;}" in STYLE_CSS
session_time_block = STYLE_CSS[
STYLE_CSS.find(".session-time{"):
STYLE_CSS.find(".session-time.is-hidden")
]
assert "position:absolute;" in session_time_block
assert "right:10px;" in session_time_block
assert ".session-item:hover .session-time" in STYLE_CSS
assert ".session-item.streaming:not(:hover):not(:focus-within):not(.menu-open) .session-actions" in STYLE_CSS
assert ".session-item.unread:not(:hover):not(:focus-within):not(.menu-open) .session-actions" in STYLE_CSS
def test_sidebar_uses_local_inflight_state_for_immediate_spinner():
messages_js = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text()
assert "const isLocalStreaming=Boolean(" in SESSIONS_JS
assert "(isActive&&S.busy)" in SESSIONS_JS
assert "INFLIGHT[s.session_id]" in SESSIONS_JS
assert "const isStreaming=Boolean(s.is_streaming||isLocalStreaming);" in SESSIONS_JS
assert "if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();" in messages_js
def test_date_group_caret_expanded_down_collapsed_right():
assert "caret.textContent='\\u25BE';" in SESSIONS_JS
assert ".session-date-caret{" in STYLE_CSS
caret_block = STYLE_CSS[
STYLE_CSS.find(".session-date-caret{"):
STYLE_CSS.find(".session-date-caret.collapsed")
]
assert "transform:rotate(0deg);" in caret_block
assert ".session-date-caret.collapsed{transform:rotate(-90deg);}" in STYLE_CSS
def test_apperror_path_calls_render_session_list():
"""apperror handler must call renderSessionList() to clear the streaming indicator
+113
View File
@@ -65,6 +65,64 @@ def _read_index(index_file):
return json.loads(index_file.read_text(encoding="utf-8"))
def test_compact_exposes_last_message_at_from_message_timestamp():
s = Session(
session_id="sess_time",
title="Time",
updated_at=300.0,
messages=[
{"role": "user", "content": "old", "_ts": 100.0},
{"role": "tool", "content": "ignore", "timestamp": 400.0},
{"role": "assistant", "content": "latest", "timestamp": 200.0},
],
)
compact = s.compact()
assert compact["updated_at"] == 300.0
assert compact["last_message_at"] == 200.0
def test_all_sessions_backfills_last_message_at_for_legacy_index_rows():
index_file = models.SESSION_INDEX_FILE
s = Session(
session_id="sess_legacy_index",
title="Legacy Index",
updated_at=300.0,
messages=[{"role": "assistant", "content": "reply", "_ts": 100.0}],
)
s.path.write_text(json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8")
_write_index_file(
index_file,
[
{
"session_id": s.session_id,
"title": s.title,
"updated_at": s.updated_at,
"workspace": s.workspace,
"model": s.model,
"message_count": 1,
"created_at": s.created_at,
"pinned": False,
"archived": False,
}
],
)
rows = models.all_sessions()
assert rows[0]["session_id"] == s.session_id
assert rows[0]["last_message_at"] == 100.0
# Backfill must also be persisted to the index so subsequent /api/sessions
# polls don't re-read every legacy session file. Without this, a 5-second
# poll cycle re-loads every legacy session JSON on every tick until each
# session is independently saved.
persisted = _read_index(index_file)
assert persisted[0]["session_id"] == s.session_id
assert persisted[0].get("last_message_at") == 100.0
# ── 6. test_incremental_patch_correctness ─────────────────────────────────
def test_incremental_patch_correctness():
@@ -178,6 +236,61 @@ def test_incremental_update_prunes_stale_entries():
assert "ghost_sid" not in ids, "stale entry with no backing file must be pruned"
def test_load_metadata_only_does_not_parse_large_message_body():
"""Large sessions must keep the metadata-only path cheap."""
s = Session(
session_id="sess_large",
title="Large Session",
messages=[{"role": "assistant", "content": "x" * 200_000}],
tool_calls=[{"id": "tool_1", "name": "read_file", "result": "y" * 10_000}],
input_tokens=123,
output_tokens=45,
)
s.save()
with patch.object(Session, "load", side_effect=AssertionError("full load should not run")):
meta = Session.load_metadata_only("sess_large")
assert meta is not None
assert meta.session_id == "sess_large"
assert meta.title == "Large Session"
assert meta.input_tokens == 123
assert meta.output_tokens == 45
assert meta.messages == []
assert meta.tool_calls == []
assert meta.compact()["message_count"] == 1
def test_metadata_only_get_session_does_not_poison_full_session_cache():
s = Session(
session_id="sess_cache",
title="Cache Guard",
messages=[{"role": "user", "content": "hi"}],
)
s.save(skip_index=True)
meta = models.get_session("sess_cache", metadata_only=True)
assert meta.messages == []
assert "sess_cache" not in models.SESSIONS
full = models.get_session("sess_cache")
assert full.messages == [{"role": "user", "content": "hi"}]
assert models.SESSIONS["sess_cache"] is full
def test_session_save_does_not_persist_metadata_message_count_hint():
s = Session(
session_id="sess_private_hint",
title="Private Hint",
messages=[{"role": "user", "content": "hi"}],
)
s._metadata_message_count = 10
s.save(skip_index=True)
payload = json.loads(s.path.read_text(encoding="utf-8"))
assert "_metadata_message_count" not in payload
# ── 8. test_first_call_full_rebuild ──────────────────────────────────────
def test_first_call_full_rebuild():
+51
View File
@@ -0,0 +1,51 @@
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_messages_zero_skips_effective_model_resolution():
src = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
assert re.search(
r"effective_model\s*=\s*\(\s*"
r"_resolve_effective_session_model_for_display\(s\)\s*"
r"if resolve_model\s*else None\s*\)",
src,
), "messages=0 metadata requests must not resolve the model catalog"
assert 'resolve_model_default = "1" if load_messages else "0"' in src
def test_full_message_load_updates_viewed_count_after_metadata_fast_path():
src = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
assert "_setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0));" in src
assert "_setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));" in src
def test_lazy_message_load_skips_model_resolution():
src = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
assert "messages=1&resolve_model=0" in src
def test_session_switch_defers_model_resolution_without_blocking():
src = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
ui = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
assert "messages=0&resolve_model=0" in src
assert "function _resolveSessionModelForDisplaySoon" in src
assert "messages=0&resolve_model=1" in src
assert "_modelResolutionDeferred=true" in src
assert "deferModelCorrection" in ui
assert "if(!deferModelCorrection)" in ui
def test_boot_does_not_block_session_restore_on_model_catalog():
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
assert "if(s.default_model) window._defaultModel=s.default_model;" in src
assert "const _modelDropdownReady=populateModelDropdown().then" in src
assert "window._modelDropdownReady=_modelDropdownReady" in src
assert "await populateModelDropdown()" not in src
@@ -29,6 +29,7 @@ def _run_session_time_case(script_body: str) -> dict:
functions = "\n\n".join(
_extract_function(SESSIONS_JS, name)
for name in (
"_sessionTimestampMs",
"_localDayOrdinal",
"_sessionCalendarBoundaries",
"_formatSessionDate",
@@ -65,6 +66,7 @@ def _run_session_time_case(script_body: str) -> dict:
def test_session_sidebar_js_has_dynamic_relative_time_helpers():
assert "function _sessionTimestampMs" in SESSIONS_JS
assert "function _sessionCalendarBoundaries" in SESSIONS_JS
assert "function _formatRelativeSessionTime" in SESSIONS_JS
assert "function _sessionTimeBucketLabel" in SESSIONS_JS
@@ -86,6 +88,22 @@ def test_session_sidebar_renders_relative_time_and_meta_rows():
assert "const ONE_DAY=86400000;" not in SESSIONS_JS
def test_session_timestamp_prefers_last_message_at_over_metadata_updated_at():
result = _run_session_time_case(
"""
const session = {
created_at: 1776441348,
updated_at: 1777086443,
last_message_at: 1776441972,
};
process.stdout.write(JSON.stringify({
timestampMs: _sessionTimestampMs(session),
}));
"""
)
assert result["timestampMs"] == 1776441972 * 1000
def test_relative_time_uses_calendar_boundaries_and_year_for_old_sessions():
result = _run_session_time_case(
"""
+155 -3
View File
@@ -72,9 +72,14 @@ class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
def _run_with_config(self, tg_config, expected_timeout):
from api.streaming import generate_title_raw_via_aux
mock_resp = MagicMock()
mock_resp.choices = [MagicMock()]
mock_resp.choices[0].message.content = 'Test Title'
mock_resp = types.SimpleNamespace(
choices=[
types.SimpleNamespace(
message=types.SimpleNamespace(content='Test Title'),
finish_reason='stop',
)
]
)
captured = {}
@@ -118,6 +123,153 @@ class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
)
class TestReasoningModelTitleGeneration(unittest.TestCase):
"""Regression coverage for reasoning models that spend output budget on reasoning."""
def test_title_budget_defaults_to_reasoning_safe_value(self):
"""Title generation should not use a tiny output cap that starves final content."""
from api.streaming import _title_completion_budget, _title_retry_completion_budget
self.assertEqual(_title_completion_budget(), 512)
self.assertEqual(_title_retry_completion_budget(), 1024)
def test_aux_retries_empty_reasoning_length_response_with_larger_budget(self):
"""If a reasoning model returns empty content at finish_reason=length, retry once."""
from api.streaming import generate_title_raw_via_aux
responses = [
{
'choices': [
{
'message': {'content': '', 'reasoning': 'long hidden reasoning'},
'finish_reason': 'length',
}
]
},
{'choices': [{'message': {'content': 'Useful Session Title'}, 'finish_reason': 'stop'}]},
]
captured_budgets = []
def fake_call_llm(**kwargs):
captured_budgets.append(kwargs.get('max_tokens'))
return responses.pop(0)
with _patch_tg_config({'provider': 'ollama', 'model': 'kimi-k2.6', 'base_url': 'https://ollama.com/v1'}):
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
result, status = generate_title_raw_via_aux(
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
)
self.assertEqual(result, 'Useful Session Title')
self.assertEqual(status, 'llm_aux_retry')
self.assertEqual(captured_budgets, [512, 1024])
def test_aux_returns_specific_status_when_reasoning_retry_still_empty(self):
"""Diagnostics should expose the provider failure mode instead of generic llm_error_aux."""
from api.streaming import generate_title_raw_via_aux
def empty_length_response(**kwargs):
return {
'choices': [
{
'message': {'content': '', 'reasoning': 'still reasoning'},
'finish_reason': 'length',
}
]
}
with _patch_tg_config({'provider': 'ollama', 'model': 'kimi-k2.6', 'base_url': 'https://ollama.com/v1'}):
with patch('agent.auxiliary_client.call_llm', side_effect=empty_length_response, create=True):
result, status = generate_title_raw_via_aux(
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
)
self.assertIsNone(result)
self.assertEqual(status, 'llm_length_aux')
def test_agent_route_retries_empty_reasoning_length_response(self):
"""The active-agent route should get the same reasoning-model retry path as aux."""
from api.streaming import generate_title_raw_via_agent
responses = [
{
'choices': [
{
'message': {'content': '', 'reasoning': 'long hidden reasoning'},
'finish_reason': 'length',
}
]
},
{'choices': [{'message': {'content': 'Agent Session Title'}, 'finish_reason': 'stop'}]},
]
captured_budgets = []
def fake_create(**kwargs):
captured_budgets.append(kwargs.get('max_tokens') or kwargs.get('max_completion_tokens'))
return responses.pop(0)
client = types.SimpleNamespace(
chat=types.SimpleNamespace(
completions=types.SimpleNamespace(create=fake_create)
)
)
agent = MagicMock()
agent.api_mode = 'openai'
agent.provider = 'ollama'
agent.model = 'kimi-k2.6'
agent.base_url = 'https://ollama.com/v1'
agent.reasoning_config = None
agent._build_api_kwargs.return_value = {}
agent._ensure_primary_openai_client.return_value = client
result, status = generate_title_raw_via_agent(
agent,
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
)
self.assertEqual(result, 'Agent Session Title')
self.assertEqual(status, 'llm_retry')
self.assertEqual(captured_budgets, [512, 1024])
self.assertIsNone(agent.reasoning_config)
@patch('api.streaming._aux_title_configured', return_value=True)
@patch('api.streaming._generate_llm_session_title_via_aux')
@patch('api.streaming.get_session')
def test_fallback_title_status_keeps_underlying_llm_reason(
self, mock_get_session, mock_aux_title, mock_configured,
):
"""Local fallback should not hide that the LLM failed because it hit length."""
from api.streaming import _run_background_title_update
mock_session = MagicMock()
mock_session.title = 'Untitled'
mock_session.llm_title_generated = False
mock_session.messages = [
{'role': 'user', 'content': 'Hey nur ein kurzer Test'},
{'role': 'assistant', 'content': 'Alles klar, ich helfe dir dabei.'},
]
mock_get_session.return_value = mock_session
mock_aux_title.return_value = (None, 'llm_length_aux', '')
events = []
_run_background_title_update(
session_id='reasoning-title-session',
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
placeholder_title='Untitled',
put_event=lambda event_type, data: events.append((event_type, data)),
agent=None,
)
title_status = [data for event_type, data in events if event_type == 'title_status']
self.assertTrue(title_status)
self.assertEqual(title_status[0]['status'], 'fallback')
self.assertEqual(title_status[0]['reason'], 'local_summary:llm_length_aux')
class TestAuxTitleTimeoutEdgeCases(unittest.TestCase):
"""Comment 4: _aux_title_timeout must reject zero, negative, and non-numeric values."""