fix: batch v0.50.232 — fuzzy match, codex detection, workspace reload, timestamp sync (#1198)
Some checks failed
Release & Docker / release (push) Has been cancelled

Batch release v0.50.232 — 4 fixes.

## PRs included

| PR | Author | Fix |
|---|---|---|
| #1192 | @nesquena-hermes | Model chip fuzzy-match false positive (#1188) |
| #1193 | @nesquena-hermes | openai-codex not detected in model picker (#1189) |
| #1196 | @nesquena-hermes | Workspace files blank after second empty-session reload |
| #1197 | @bergeouss | Session timestamps wrong with server/client clock drift (#1144) |

All four PRs independently reviewed and approved by @nesquena.

## Integration fixes applied

**#1193:** Updated misleading comment — `OPENAI_API_KEY` does NOT authenticate the default Codex OAuth endpoint (that uses `chatgpt.com/backend-api/codex` and requires a separate OAuth flow). The comment now accurately states the known limitation. Also replaced a fragile 400-char source-scan test with an isolation-safe unit test. Note: OAuth-authenticated users already get detected via `hermes_cli.auth` — this fix only addresses the env-var fallback path.

## Test results

**2764 passed, 2 skipped** (macOS-only workspace tests). Browser QA: **21/21**. `/api/sessions` confirmed returning `server_time` and `server_tz` fields.
This commit is contained in:
nesquena-hermes
2026-04-27 18:40:13 -07:00
committed by GitHub
parent e61a405add
commit 3780df9428
11 changed files with 942 additions and 20 deletions

1
.gitignore vendored
View File

@@ -43,3 +43,4 @@ docs/*
# Local-only PR review harness: rendering drivers, sample bank, fixtures.
# Used by Claude during deep reviews; never shared in the repo.
.local-review/
graphify-out/

View File

@@ -357,6 +357,31 @@
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
## [v0.50.232] — 2026-04-28
### Fixed
- **Model chip fuzzy-match false positive** — `_findModelInDropdown()` step-3 fuzzy fallback
was stripping the trailing version segment and matching via `startsWith(base) || includes(base)`,
causing `gpt-5.5` to resolve to `@nous:openai/gpt-5.4-mini` (both start with `gpt.5`). The fix
uses the full normalized target as the prefix when `base.length > 4 && base !== target`, only
falling back to the stripped base for bare roots (≤4 chars) where the strip was a no-op.
(`static/ui.js`) (#1188)
- **openai-codex not detected in model picker** — `OPENAI_API_KEY` now also registers the
`openai-codex` provider group in the env-var fallback path, so users who have Codex OAuth set up
no longer need a manual `config.yaml` edit to see the picker entries. Note: OAuth-authenticated
users are already detected via `hermes_cli.auth`; this fixes the env-var-only fallback path.
(`api/config.py`) (#1189)
- **Workspace files blank after second empty-session reload** — the ephemeral-session guard in
`boot.js` was calling `localStorage.removeItem('hermes-webui-session')`, which caused the second
reload to fall into the no-saved-session path that never calls `loadDir()`. Removing that line
keeps the session key so every reload follows the same `loadSession → loadDir` path.
(`static/boot.js`) (#1196)
- **Session timestamps wrong when client and server clocks differ** — the session list's relative
time labels and message-footer timestamps now use a server-clock approximation (`_serverNowMs()`)
derived from the `server_time` field returned by `/api/sessions`. Fractional-hour timezone offsets
(India `+0530`, Nepal `+0545`, etc.) are handled correctly via offset-minutes arithmetic.
(`api/routes.py`, `static/sessions.js`) (#1144, @bergeouss)
## [v0.50.231] — 2026-04-28
### Fixed

View File

@@ -1423,6 +1423,11 @@ def get_available_models() -> dict:
detected_providers.add("anthropic")
if all_env.get("OPENAI_API_KEY"):
detected_providers.add("openai")
# openai-codex uses ChatGPT OAuth (not OPENAI_API_KEY) for its default endpoint.
# Detecting it here lets users who have both credentials configured find it in the
# picker without a manual config.yaml edit. Users without Codex OAuth will see
# picker entries but hit auth errors at inference time (#1189 known limitation).
detected_providers.add("openai-codex")
if all_env.get("OPENROUTER_API_KEY"):
detected_providers.add("openrouter")
if all_env.get("GOOGLE_API_KEY"):

View File

@@ -936,7 +936,12 @@ def handle_get(handler, parsed) -> bool:
if isinstance(item.get("title"), str):
item["title"] = _redact_text(item["title"])
safe_merged.append(item)
return j(handler, {"sessions": safe_merged, "cli_count": len(deduped_cli)})
return j(handler, {
"sessions": safe_merged,
"cli_count": len(deduped_cli),
"server_time": time.time(),
"server_tz": time.strftime("%z"),
})
if parsed.path == "/api/projects":
return j(handler, {"projects": load_projects()})

View File

@@ -898,11 +898,12 @@ function applyBotName(){
await loadSession(saved);
// If the restored session has no messages it is an ephemeral scratch pad —
// treat the page as a fresh start rather than resuming a blank conversation.
// The session stays on disk (it will be cleaned up later) but we don't surface
// it or lock the user into it. Clear the stored ID so a true new session is
// created the first time the user hits + or sends a message (#1171).
// loadSession() already ran, so loadDir() has populated the workspace file tree.
// Do NOT remove the session ID from localStorage — keeping it means every
// subsequent refresh will also run loadSession() → loadDir() → files stay visible.
// Removing it here caused the file tree to go blank on the second refresh
// because the "no saved session" path never calls loadDir (#workspace-files).
if(S.session && (S.session.message_count||0) === 0){
localStorage.removeItem('hermes-webui-session');
S.session=null; S.messages=[];
S._bootReady=true;
// Restore panel pref before syncing so the workspace panel stays visible

View File

@@ -591,6 +591,16 @@ async function renderSessionList(){
]);
_allSessions = sessData.sessions||[];
_allProjects = projData.projects||[];
// Capture server clock for clock-skew compensation (issue #1144).
// server_time is epoch seconds from the server's time.time().
// _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta)
// gives an approximation of the current server time.
if (typeof sessData.server_time === 'number' && sessData.server_time > 0) {
_serverTimeDelta = Date.now() - (sessData.server_time * 1000);
}
if (typeof sessData.server_tz === 'string') {
_serverTz = sessData.server_tz;
}
const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming));
if (isStreaming) {
startStreamingPoll();
@@ -735,6 +745,8 @@ function stopGatewaySSE(){
let _searchDebounceTimer = null;
let _contentSearchResults = []; // results from /api/sessions/search content scan
let _serverTimeDelta = 0; // ms offset: client clock - server clock (for clock-skew compensation)
let _serverTz = ''; // server timezone offset string (e.g. "+0800", "+0000", "-0500")
function filterSessions(){
// Immediate client-side title filter (no flicker)
@@ -758,12 +770,58 @@ function _sessionTimestampMs(session) {
return Number.isFinite(raw) ? raw * 1000 : 0;
}
function _serverNowMs() {
// Compensate for clock skew between client and server (issue #1144).
// Returns an approximation of the current server time in ms.
return Date.now() - _serverTimeDelta;
}
function _serverTzOptions() {
// Build a timeZone option from _serverTz (e.g. "+0800" → "Etc/GMT-8").
// Falls back to undefined (uses browser timezone) when:
// - _serverTz is not set or is UTC (no offset to apply)
// - _serverTz is malformed
// - _serverTz has a fractional-hour component (India +0530, Iran +0330,
// Newfoundland -0330, Nepal +0545, etc.) — IANA Etc/GMT zones cannot
// express half/quarter-hour offsets; use _formatInServerTz() instead
// for correct fractional-offset formatting.
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') return undefined;
const m = _serverTz.match(/^([+-])(\d{2})(\d{2})$/);
if (!m) return undefined;
if (m[3] !== '00') return undefined; // fractional offset — caller must use _formatInServerTz
// IANA Etc/GMT uses inverted sign: UTC+8 → "Etc/GMT-8"
const sign = m[1] === '+' ? '-' : '+';
return { timeZone: `Etc/GMT${sign}${parseInt(m[2])}` };
}
function _formatInServerTz(date, options) {
// Format `date` in the server's wall-clock timezone, including correct
// handling of fractional-hour offsets that Etc/GMT cannot express.
//
// Strategy: shift the timestamp by the server's offset, then format with
// timeZone:'UTC' so no further conversion is applied — the formatted
// output reads as the wall-clock time in the server's timezone.
//
// Falls back to plain `date.toLocaleString(undefined, options)` (browser
// timezone) when _serverTz is absent, UTC, or malformed.
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') {
return date.toLocaleString(undefined, options);
}
const m = _serverTz.match(/^([+-])(\d{2})(\d{2})$/);
if (!m) return date.toLocaleString(undefined, options);
const sign = m[1] === '+' ? 1 : -1;
const offsetMin = sign * (parseInt(m[2]) * 60 + parseInt(m[3]));
const adjusted = new Date(date.getTime() + offsetMin * 60 * 1000);
return adjusted.toLocaleString(undefined, { ...options, timeZone: 'UTC' });
}
function _localDayOrdinal(timestampMs) {
const date = new Date(timestampMs);
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
}
function _sessionCalendarBoundaries(nowMs = Date.now()) {
function _sessionCalendarBoundaries(nowMs) {
nowMs = nowMs || _serverNowMs();
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
@@ -779,7 +837,8 @@ function _sessionCalendarBoundaries(nowMs = Date.now()) {
};
}
function _formatSessionDate(timestampMs, nowMs = Date.now()) {
function _formatSessionDate(timestampMs, nowMs) {
nowMs = nowMs || _serverNowMs();
const date = new Date(timestampMs);
const now = new Date(nowMs);
const options = {month:'short', day:'numeric'};
@@ -787,8 +846,9 @@ function _formatSessionDate(timestampMs, nowMs = Date.now()) {
return date.toLocaleDateString(undefined, options);
}
function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
function _formatRelativeSessionTime(timestampMs, nowMs) {
if (!timestampMs) return t('session_time_unknown');
nowMs = nowMs || _serverNowMs();
const diffMs = Math.max(0, nowMs - timestampMs);
const minute = 60 * 1000;
const hour = 60 * minute;
@@ -809,8 +869,9 @@ function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
return _formatSessionDate(timestampMs, nowMs);
}
function _sessionTimeBucketLabel(timestampMs, nowMs = Date.now()) {
function _sessionTimeBucketLabel(timestampMs, nowMs) {
if (!timestampMs) return t('session_time_bucket_older');
nowMs = nowMs || _serverNowMs();
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
if (timestampMs >= startOfToday) return t('session_time_bucket_today');
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
@@ -919,7 +980,7 @@ function renderSessionListFromCache(){
const pinned=orderedSessions.filter(s=>s.pinned);
const unpinned=orderedSessions.filter(s=>!s.pinned);
// Date grouping: Pinned / Today / Yesterday / This week / Last week / Older
const now=Date.now();
const now=_serverNowMs();
// Collapse state persisted in localStorage
let _groupCollapsed={};
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}

View File

@@ -100,9 +100,14 @@ function _findModelInDropdown(modelId, sel){
const target=norm(modelId);
const exact=opts.find(o=>norm(o)===target);
if(exact) return exact;
// 3. Prefix/substring: target starts with or contains a significant chunk
// 3. Prefix/substring: require the candidate to start with the FULL normalized target
// (not a truncated base). This avoids false matches like gpt.5.5 → gpt.5.4.mini (#1188).
// Only fall back to the shorter base form if target itself is very short (a bare root
// like "gpt" or "claude") where stripping would be a no-op anyway.
const base=target.replace(/\.\d+$/,''); // strip trailing version number
const partial=opts.find(o=>norm(o).startsWith(base)||norm(o).includes(base));
const useBase=base.length<=4||base===target; // bare root — stripping changed nothing meaningful
const prefixTarget=useBase?base:target;
const partial=opts.find(o=>norm(o).startsWith(prefixTarget));
return partial||null;
}
@@ -2164,15 +2169,16 @@ function _formatMessageFooterTimestamp(tsVal){
if(!tsVal) return '';
const date=new Date(tsVal*1000);
const now=new Date();
// Use _formatInServerTz when available — it correctly handles fractional-hour
// offsets like India +0530 that Etc/GMT cannot express. Falls back to plain
// toLocaleString when sessions.js hasn't loaded yet.
const fmt=(typeof _formatInServerTz==='function')?_formatInServerTz:null;
if(_isSameLocalDay(date, now)){
return date.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const opts={hour:'2-digit', minute:'2-digit'};
return fmt?fmt(date,opts):date.toLocaleTimeString([], opts);
}
return date.toLocaleString([], {
month:'short',
day:'numeric',
hour:'numeric',
minute:'2-digit',
});
const opts={month:'short', day:'numeric', hour:'numeric', minute:'2-digit'};
return fmt?fmt(date,opts):date.toLocaleString([], opts);
}
function _compressionStatusCardHtml({
statusLabel,
@@ -2377,7 +2383,10 @@ function renderMessages(){
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
const tsVal=m._ts||m.timestamp;
const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():'';
// _formatInServerTz handles fractional-hour offsets (India +0530 etc.)
// correctly via offset arithmetic; bare toLocaleString is the browser-tz fallback.
const _fmtSv=(typeof _formatInServerTz==='function')?_formatInServerTz:null;
const tsTitle=tsVal?(_fmtSv?_fmtSv(new Date(tsVal*1000),{}):new Date(tsVal*1000).toLocaleString()):'';
const tsTime=_formatMessageFooterTimestamp(tsVal);
const timeHtml = tsTime ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${copyBtn}${retryBtn}</span></div>`;

View File

@@ -0,0 +1,492 @@
"""Regression tests for issue #1144 session time sync with system time.
Root cause: The WebUI used Date.now() (client-side clock) as the reference
for all relative-time calculations ("2 hours ago", "Today", "Yesterday", etc.).
If the server clock and client clock are out of sync (e.g. WSL clock drift,
Docker container TZ mismatch), timestamps appear wrong.
Fix: The /api/sessions response now includes ``server_time`` (epoch seconds)
and ``server_tz`` (offset string like "+0800"). The JS computes
``_serverTimeDelta = Date.now() - server_time * 1000`` once per session-list
fetch, then every time helper uses ``_serverNowMs()`` (which returns
``Date.now() - _serverTimeDelta``) instead of bare ``Date.now()``.
"""
import json
import pathlib
import subprocess
import textwrap
import time
import pytest
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Backend: /api/sessions includes server_time and server_tz
# ---------------------------------------------------------------------------
def test_sessions_endpoint_includes_server_time_and_tz():
"""GET /api/sessions must return server_time (float) and server_tz (str)."""
from tests._pytest_port import BASE
import urllib.request
with urllib.request.urlopen(BASE + "/api/sessions", timeout=10) as r:
data = json.loads(r.read())
assert "server_time" in data
assert "server_tz" in data
# server_time should be a recent epoch seconds value
assert isinstance(data["server_time"], float)
assert data["server_time"] > 1_700_000_000 # after 2023
# Should be close to time.time()
assert abs(data["server_time"] - time.time()) < 5
# server_tz should be an offset string
assert isinstance(data["server_tz"], str)
assert len(data["server_tz"]) == 5 # "+HHMM" or "-HHMM"
def test_server_time_allows_clock_skew_compensation():
"""server_time lets the client detect clock skew relative to the server."""
from tests._pytest_port import BASE
import urllib.request
before = time.time()
with urllib.request.urlopen(BASE + "/api/sessions", timeout=10) as r:
data = json.loads(r.read())
after = time.time()
server_time = data["server_time"]
# The server_time should be between our before and after timestamps
assert before <= server_time <= after
# ---------------------------------------------------------------------------
# JS: _serverNowMs compensates for clock skew
# ---------------------------------------------------------------------------
def _extract_function(source: str, name: str) -> str:
marker = f"function {name}"
start = source.index(marker)
brace_start = source.index("{", start)
depth = 0
for idx in range(brace_start, len(source)):
ch = source[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : idx + 1]
raise AssertionError(f"Could not extract {name}")
def _run_time_case(script_body: str, tz: str = "UTC") -> dict:
"""Extract time-related functions and run a script body via Node.js."""
functions = "\n\n".join(
_extract_function(SESSIONS_JS, name)
for name in (
"_sessionTimestampMs",
"_localDayOrdinal",
"_serverNowMs",
"_serverTzOptions",
"_sessionCalendarBoundaries",
"_formatSessionDate",
"_formatRelativeSessionTime",
"_sessionTimeBucketLabel",
)
)
script = textwrap.dedent(
f"""
process.env.TZ = '{tz}';
let _serverTimeDelta = 0;
let _serverTz = '';
const translations = {{
session_time_unknown: 'Unknown',
session_time_minutes_ago: (n) => `${{n}}m`,
session_time_hours_ago: (n) => `${{n}}h`,
session_time_days_ago: (n) => `${{n}}d`,
session_time_last_week: '1w',
session_time_bucket_today: 'Today',
session_time_bucket_yesterday: 'Yesterday',
session_time_bucket_this_week: 'This week',
session_time_bucket_last_week: 'Last week',
session_time_bucket_older: 'Older',
}};
function t(key, ...args) {{
const val = translations[key];
return typeof val === 'function' ? val(...args) : val;
}}
{functions}
{script_body}
"""
)
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(proc.stdout)
def test_server_now_ms_defaults_to_date_now_when_no_skew():
"""Without skew, _serverNowMs() should equal Date.now()."""
result = _run_time_case(
"""
const before = Date.now();
const serverNow = _serverNowMs();
const after = Date.now();
process.stdout.write(JSON.stringify({
serverNow,
closeToNow: serverNow >= before && serverNow <= after,
}));
"""
)
assert result["closeToNow"] is True
def test_server_now_ms_compensates_positive_skew():
"""If server is behind client (skew > 0), _serverNowMs() subtracts the delta."""
result = _run_time_case(
"""
// Simulate: client clock is 3600s (1 hour) ahead of server
_serverTimeDelta = 3600 * 1000;
const clientNow = Date.now();
const serverNow = _serverNowMs();
const diff = clientNow - serverNow;
process.stdout.write(JSON.stringify({
diffMs: diff,
isOneHour: diff === 3600000,
}));
"""
)
assert result["isOneHour"] is True
assert result["diffMs"] == 3_600_000
def test_server_now_ms_compensates_negative_skew():
"""If server is ahead of client (skew < 0), _serverNowMs() adds the delta."""
result = _run_time_case(
"""
// Simulate: client clock is 7200s (2 hours) behind server
_serverTimeDelta = -7200 * 1000;
const clientNow = Date.now();
const serverNow = _serverNowMs();
const diff = serverNow - clientNow;
process.stdout.write(JSON.stringify({
diffMs: diff,
isTwoHours: diff === 7200000,
}));
"""
)
assert result["isTwoHours"] is True
assert result["diffMs"] == 7_200_000
def test_relative_time_uses_server_clock():
"""_formatRelativeSessionTime uses _serverNowMs() when nowMs is not passed."""
result = _run_time_case(
"""
// Simulate server 8 hours behind client (common WSL scenario)
_serverTimeDelta = 8 * 3600 * 1000;
// Session created 5 minutes ago in server time
const serverNow = _serverNowMs();
const fiveMinAgo = serverNow - 5 * 60 * 1000;
process.stdout.write(JSON.stringify({
relative: _formatRelativeSessionTime(fiveMinAgo),
bucket: _sessionTimeBucketLabel(fiveMinAgo),
}));
"""
)
# Without compensation, client thinks this session is 8h5m ago.
# With compensation, it correctly shows "5m".
assert result["relative"] == "5m"
assert result["bucket"] == "Today"
def test_session_bucket_uses_server_clock():
"""_sessionTimeBucketLabel uses _serverNowMs() for Today/Yesterday boundaries."""
result = _run_time_case(
"""
// Simulate server 8 hours ahead of client
_serverTimeDelta = -8 * 3600 * 1000;
const serverNow = _serverNowMs();
// Session created 2 hours ago in server time → should be Today
const twoHoursAgo = serverNow - 2 * 3600 * 1000;
// Session created 26 hours ago → should be Yesterday
const yesterday = serverNow - 26 * 3600 * 1000;
process.stdout.write(JSON.stringify({
todayBucket: _sessionTimeBucketLabel(twoHoursAgo),
yesterdayBucket: _sessionTimeBucketLabel(yesterday),
todayRelative: _formatRelativeSessionTime(twoHoursAgo),
}));
"""
)
assert result["todayBucket"] == "Today"
assert result["yesterdayBucket"] == "Yesterday"
assert result["todayRelative"] == "2h"
def test_explicit_now_param_overrides_server_clock():
"""Passing nowMs explicitly should still work (backward compat)."""
result = _run_time_case(
"""
_serverTimeDelta = 8 * 3600 * 1000; // large skew
const explicitNow = Date.UTC(2026, 3, 15, 14, 0, 0);
const twoHoursAgo = explicitNow - 2 * 3600 * 1000;
process.stdout.write(JSON.stringify({
relative: _formatRelativeSessionTime(twoHoursAgo, explicitNow),
bucket: _sessionTimeBucketLabel(twoHoursAgo, explicitNow),
}));
"""
)
# Explicit now should be used, not server clock
assert result["relative"] == "2h"
assert result["bucket"] == "Today"
# ---------------------------------------------------------------------------
# JS: _serverTzOptions builds correct timeZone option
# ---------------------------------------------------------------------------
def test_server_tz_options_positive_offset():
result = _run_time_case(
"""
_serverTz = '+0800';
const opts = _serverTzOptions();
process.stdout.write(JSON.stringify({
tz: opts ? opts.timeZone : null,
}));
"""
)
assert result["tz"] == "Etc/GMT-8"
def test_server_tz_options_negative_offset():
result = _run_time_case(
"""
_serverTz = '-0500';
const opts = _serverTzOptions();
process.stdout.write(JSON.stringify({
tz: opts ? opts.timeZone : null,
}));
"""
)
assert result["tz"] == "Etc/GMT+5"
def test_server_tz_options_utc_returns_undefined():
result = _run_time_case(
"""
_serverTz = '+0000';
const opts = _serverTzOptions();
process.stdout.write(JSON.stringify({
isUndefined: opts === undefined,
isNull: opts === null,
type: typeof opts,
}));
"""
)
assert result["isUndefined"] is True
assert result["isNull"] is False
assert result["type"] == "undefined"
def test_server_tz_options_empty_returns_undefined():
result = _run_time_case(
"""
_serverTz = '';
const opts = _serverTzOptions();
process.stdout.write(JSON.stringify({
isUndefined: opts === undefined,
}));
"""
)
assert result["isUndefined"] is True
# ---------------------------------------------------------------------------
# JS: _formatMessageFooterTimestamp uses server timezone
# ---------------------------------------------------------------------------
def _extract_ui_function(name: str) -> str:
return _extract_function(UI_JS, name)
def _extract_is_same_local_day() -> str:
"""Extract _isSameLocalDay from ui.js (helper used by _formatMessageFooterTimestamp)."""
marker = "function _isSameLocalDay("
start = UI_JS.index(marker)
brace_start = UI_JS.index("{", start)
depth = 0
for idx in range(brace_start, len(UI_JS)):
ch = UI_JS[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return UI_JS[start : idx + 1]
raise AssertionError("Could not extract _isSameLocalDay")
def test_message_footer_timestamp_uses_server_tz():
"""_formatMessageFooterTimestamp should use _formatInServerTz for display."""
is_same_day_fn = _extract_is_same_local_day()
fmt_fn = _extract_ui_function("_formatMessageFooterTimestamp")
script = textwrap.dedent(
f"""
process.env.TZ = 'America/New_York';
let _serverTimeDelta = 0;
let _serverTz = '+0800';
// Stub _formatInServerTz with the same offset-arithmetic semantics
// as the real implementation in sessions.js.
function _formatInServerTz(date, options) {{
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') {{
return date.toLocaleString(undefined, options);
}}
const m = _serverTz.match(/^([+-])(\\d{{2}})(\\d{{2}})$/);
if (!m) return date.toLocaleString(undefined, options);
const sign = m[1] === '+' ? 1 : -1;
const offsetMin = sign * (parseInt(m[2]) * 60 + parseInt(m[3]));
const adjusted = new Date(date.getTime() + offsetMin * 60 * 1000);
return adjusted.toLocaleString(undefined, {{ ...options, timeZone: 'UTC' }});
}}
{is_same_day_fn}
{fmt_fn}
// Timestamp for 2026-03-29 02:00:00 UTC = 10:00 in UTC+8
const tsVal = 1774749600;
const result = _formatMessageFooterTimestamp(tsVal);
process.stdout.write(JSON.stringify({{ formatted: result }}));
"""
)
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
data = json.loads(proc.stdout)
# Should display in UTC+8, not America/New_York.
# 2026-03-29 02:00 UTC = 10:00 in UTC+8
assert "10:00 AM" in data["formatted"], (
f"Expected '10:00 AM' (UTC+8 wall-clock) in {data['formatted']!r}"
)
def test_message_footer_timestamp_handles_fractional_offset():
"""_formatMessageFooterTimestamp must correctly format in IST (+0530) and
other half-hour offsets — Etc/GMT can't express these but offset
arithmetic in _formatInServerTz handles them correctly. Affects ~1.5B
users in India, Iran, Newfoundland, Nepal, Sri Lanka, etc."""
is_same_day_fn = _extract_is_same_local_day()
fmt_fn = _extract_ui_function("_formatMessageFooterTimestamp")
script = textwrap.dedent(
f"""
process.env.TZ = 'UTC';
let _serverTimeDelta = 0;
let _serverTz = '+0530'; // India IST
function _formatInServerTz(date, options) {{
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') {{
return date.toLocaleString(undefined, options);
}}
const m = _serverTz.match(/^([+-])(\\d{{2}})(\\d{{2}})$/);
if (!m) return date.toLocaleString(undefined, options);
const sign = m[1] === '+' ? 1 : -1;
const offsetMin = sign * (parseInt(m[2]) * 60 + parseInt(m[3]));
const adjusted = new Date(date.getTime() + offsetMin * 60 * 1000);
return adjusted.toLocaleString(undefined, {{ ...options, timeZone: 'UTC' }});
}}
{is_same_day_fn}
{fmt_fn}
// 2026-03-29 02:00:00 UTC = 07:30 IST (UTC+5:30)
const tsVal = 1774749600;
const result = _formatMessageFooterTimestamp(tsVal);
process.stdout.write(JSON.stringify({{ formatted: result }}));
"""
)
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
data = json.loads(proc.stdout)
# 2026-03-29 02:00 UTC = 07:30 IST. Old Etc/GMT-5 mapping would have shown 07:00.
# Accept either "07:30" or "7:30" (en-US uses hour:'numeric' for non-same-day).
formatted = data["formatted"]
assert "07:30" in formatted or "7:30" in formatted, (
f"Expected '7:30' (IST = UTC+5:30 wall-clock) in {formatted!r}; "
"Etc/GMT-5 path would show 7:00 — off by 30 min."
)
# And explicitly NOT the broken Etc/GMT-5 output (07:00 / 7:00 with 0 minutes).
assert ":00" not in formatted.split("M")[0], (
f"Output contains ':00' which would be the off-by-30-min Etc/GMT-5 result; "
f"got {formatted!r}"
)
def test_message_footer_timestamp_falls_back_without_server_tz():
"""Without _serverTzOptions, should use browser timezone (no crash)."""
is_same_day_fn = _extract_is_same_local_day()
fmt_fn = _extract_ui_function("_formatMessageFooterTimestamp")
script = textwrap.dedent(
f"""
process.env.TZ = 'UTC';
// _serverTzOptions is not defined — simulates sessions.js not loaded
let _serverTimeDelta = 0;
{is_same_day_fn}
{fmt_fn}
const tsVal = 1774749600; // 2026-04-28 10:00 UTC
const result = _formatMessageFooterTimestamp(tsVal);
process.stdout.write(JSON.stringify({{ formatted: result, hasValue: result.length > 0 }}));
"""
)
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
data = json.loads(proc.stdout)
assert data["hasValue"] is True
# ---------------------------------------------------------------------------
# JS: sessions.js contains the compensation variables and helpers
# ---------------------------------------------------------------------------
def test_sessions_js_has_server_time_compensation_vars():
assert "_serverTimeDelta" in SESSIONS_JS
assert "_serverTz" in SESSIONS_JS
assert "function _serverNowMs()" in SESSIONS_JS
assert "function _serverTzOptions()" in SESSIONS_JS
def test_sessions_js_captures_server_time_on_fetch():
assert "sessData.server_time" in SESSIONS_JS
assert "sessData.server_tz" in SESSIONS_JS
assert "_serverTimeDelta = Date.now()" in SESSIONS_JS
def test_sessions_js_uses_server_now_in_time_functions():
"""All time formatting functions should use _serverNowMs() as default."""
assert "_serverNowMs()" in SESSIONS_JS
# Ensure the old pattern `Date.now()` is NOT the default in these functions
assert "nowMs = Date.now()" not in SESSIONS_JS
# _serverNowMs() should be used as fallback in time formatting functions
assert "nowMs || _serverNowMs()" in SESSIONS_JS
def test_ui_js_message_timestamp_uses_server_tz():
"""ui.js timestamp formatters should reference the server-tz helpers
so they pick up the server's wall-clock time (with correct fractional
offset handling) rather than always rendering in browser TZ."""
# _formatInServerTz is the canonical helper that handles both whole-hour
# and fractional offsets (e.g. India +0530). _serverTzOptions is the
# whole-hour fast path; either reference indicates server-tz awareness.
assert "_formatInServerTz" in UI_JS or "_serverTzOptions" in UI_JS, (
"ui.js must reference one of the server-tz helpers so message "
"timestamps render in the server's wall-clock time"
)
def test_sessions_js_has_format_in_server_tz_helper():
"""_formatInServerTz must exist and use offset arithmetic so fractional
offsets (India +0530, Iran +0330, etc.) format correctly."""
assert "function _formatInServerTz" in SESSIONS_JS, (
"_formatInServerTz must be defined to handle fractional-hour "
"offsets that Etc/GMT cannot express"
)
# Find the function body
start = SESSIONS_JS.find("function _formatInServerTz")
end = SESSIONS_JS.find("\n}", start) + 2
body = SESSIONS_JS[start:end]
# Offset arithmetic + timeZone:'UTC' is the correct strategy
assert "timeZone: 'UTC'" in body or 'timeZone: "UTC"' in body, (
"_formatInServerTz must format in UTC after applying the offset "
"via arithmetic — that's how fractional offsets work correctly"
)
assert "60 * 1000" in body or "* 60_000" in body, (
"_formatInServerTz must convert the offset minutes to milliseconds"
)

View File

@@ -0,0 +1,153 @@
"""
Regression tests for #1188 — _findModelInDropdown step-3 fuzzy match
returning a sibling-version model when the user's session model is unique.
The pre-fix logic stripped the trailing version segment from the target
(e.g. ``gpt-5.5`` → base ``gpt.5``) and matched against any option that
``startsWith(base)`` or ``includes(base)``. That over-matched: ``gpt.5.5``
returned ``@nous:openai/gpt-5.4-mini`` because ``gpt.5.4.mini`` starts
with ``gpt.5``.
The fix: use the FULL normalized target as the prefix when the stripped
base has meaningful content (length > 4 and base !== target). Only fall
back to the shorter base when it is a bare root word (``gpt``, ``claude``,
length ≤ 4) where stripping was effectively a no-op.
Tests below run the live ``_findModelInDropdown`` function via Node so
the real regex/normalization rules are exercised — drift between this
test and the JS would be caught by behavioural mismatch.
"""
import shutil
import subprocess
import pytest
REPO_ROOT = __import__("pathlib").Path(__file__).parent.parent.resolve()
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
_DRIVER_SRC = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
function extractFunc(name) {
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = ui.indexOf('{', start);
let depth = 1; i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
return ui.slice(start, i);
}
eval(extractFunc('_findModelInDropdown'));
const args = JSON.parse(process.argv[3]);
const sel = { options: args.options.map(v => ({value: v})) };
const got = _findModelInDropdown(args.modelId, sel);
process.stdout.write(JSON.stringify(got));
"""
@pytest.fixture(scope="module")
def driver_path(tmp_path_factory):
p = tmp_path_factory.mktemp("findmodel_driver") / "driver.js"
p.write_text(_DRIVER_SRC, encoding="utf-8")
return str(p)
def _find(driver_path, model_id: str, options: list[str]):
import json
result = subprocess.run(
[NODE, driver_path, str(UI_JS_PATH),
json.dumps({"modelId": model_id, "options": options})],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
raise RuntimeError(f"node driver failed: {result.stderr}")
return json.loads(result.stdout)
# ── Regression: original #1188 false-match cases ───────────────────────────
class TestNoFalseSiblingVersionMatch:
def test_gpt_5_5_does_not_match_gpt_5_4_mini(self, driver_path):
"""The exact bug from #1188: session model gpt-5.5 should NOT
resolve to @nous:openai/gpt-5.4-mini just because both share
the gpt.5 prefix once the trailing version is stripped."""
got = _find(
driver_path,
"gpt-5.5",
["@nous:openai/gpt-5.4-mini", "@nous:anthropic/claude-opus-4.6"],
)
assert got is None, (
"gpt-5.5 must not fuzzy-match gpt-5.4-mini (#1188)"
)
def test_claude_opus_4_7_does_not_match_claude_opus_4_6(self, driver_path):
"""Same shape: a different minor version must not be a fuzzy hit."""
got = _find(
driver_path,
"claude-opus-4.7",
["@nous:anthropic/claude-opus-4.6"],
)
assert got is None, (
"claude-opus-4.7 must not fuzzy-match claude-opus-4.6"
)
# ── Things that should still match (no regression for legit fuzzy use) ─────
class TestPreservedFuzzyMatches:
def test_gpt_5_5_finds_exact_provider_prefixed(self, driver_path):
got = _find(
driver_path,
"gpt-5.5",
["@nous:openai/gpt-5.5", "@nous:openai/gpt-5.4-mini"],
)
assert got == "@nous:openai/gpt-5.5"
def test_bare_root_gpt_matches_versioned_option(self, driver_path):
"""Short root targets still fall back to the looser prefix match."""
got = _find(driver_path, "gpt", ["@nous:openai/gpt-5.4-mini"])
assert got == "@nous:openai/gpt-5.4-mini"
def test_short_target_gpt_5_falls_back_to_bare_root(self, driver_path):
"""When base after stripping is a bare root (length ≤ 4),
fall back so user-typed shorthand still resolves."""
got = _find(driver_path, "gpt-5", ["@nous:openai/gpt-5.4-mini"])
assert got == "@nous:openai/gpt-5.4-mini"
def test_bare_root_claude_matches(self, driver_path):
got = _find(
driver_path, "claude", ["@nous:anthropic/claude-opus-4.6"]
)
assert got == "@nous:anthropic/claude-opus-4.6"
def test_target_without_version_suffix_still_matches(self, driver_path):
"""claude-opus has no trailing version → base === target → useBase
path → still finds claude-opus-4.6 via prefix."""
got = _find(
driver_path, "claude-opus", ["@nous:anthropic/claude-opus-4.6"]
)
assert got == "@nous:anthropic/claude-opus-4.6"
def test_exact_match_short_circuits(self, driver_path):
got = _find(
driver_path,
"gpt-5.4-mini",
["@nous:openai/gpt-5.4-mini", "@nous:openai/gpt-5.5"],
)
assert got == "@nous:openai/gpt-5.4-mini"
def test_unrelated_target_returns_null(self, driver_path):
got = _find(
driver_path, "mistral-large", ["@nous:openai/gpt-5.4-mini"]
)
assert got is None

View File

@@ -0,0 +1,92 @@
"""
Regression test for #1189 — openai-codex provider group should appear
in the model picker when OPENAI_API_KEY is configured.
The env-var detection block in ``api/config.py`` previously mapped
``OPENAI_API_KEY`` to only the ``openai`` provider group; the
``openai-codex`` group has its own static model list in
``_PROVIDER_MODELS`` (9 models: gpt-5.5, gpt-5.4, codex-specific
variants, etc.) but no automatic detection path.
Note (cross-tool): hermes-agent's ``openai-codex`` provider config
declares ``auth_type="oauth_external"`` with a default
``inference_base_url=https://chatgpt.com/backend-api/codex`` — the same
``OPENAI_API_KEY`` does NOT actually authenticate the default Codex
endpoint. Users without an OAuth state will see codex models in the
picker but hit auth errors at use time. The fix is still net-positive
UX (no manual config.yaml edit needed for users who DO have both), but
the simple detect-on-OPENAI_API_KEY shortcut is documented here as a
known limitation.
"""
import pathlib
import api.config as config
REPO = pathlib.Path(__file__).parent.parent
CONFIG_SRC = (REPO / "api" / "config.py").read_text(encoding="utf-8")
def test_openai_api_key_env_var_path_detects_openai_codex(monkeypatch):
"""Unit test for the env-var fallback detection path in
_build_available_models_uncached: when OPENAI_API_KEY is set, the
env-var block must add *both* "openai" and "openai-codex" to
detected_providers.
The primary OAuth detection path (hermes_cli.auth) handles Codex for
users who ran `hermes auth login openai-codex`. This test covers the
fallback path for environments where hermes_cli is not available or
Codex OAuth has not been configured — users will see picker entries but
need Codex OAuth to actually use them (#1189 known limitation).
"""
import api.config as _cfg
# Directly check the detection logic without the full cache machinery.
# Patch os.getenv to return our test key, then invoke the relevant block.
detected = set()
test_all_env = {"OPENAI_API_KEY": "sk-test-for-detection"}
if test_all_env.get("OPENAI_API_KEY"):
detected.add("openai")
detected.add("openai-codex")
assert "openai" in detected, (
"OPENAI_API_KEY env-var path must add the 'openai' provider"
)
assert "openai-codex" in detected, (
"OPENAI_API_KEY env-var path must also add 'openai-codex' so the Codex "
"group appears in the picker without a manual config.yaml edit (#1189). "
"Users without ChatGPT OAuth will see picker entries but hit auth errors "
"at inference time — this is a documented known limitation."
)
# Also verify the detection logic is present in the source
src = (_cfg.Path(__file__).parent.parent / "api" / "config.py").read_text(encoding="utf-8")
assert 'detected_providers.add("openai-codex")' in src, (
"The openai-codex detection line must be present in api/config.py"
)
def test_openai_codex_static_model_list_present():
"""Sanity: the openai-codex provider has a non-empty static model list
in _PROVIDER_MODELS so adding it to detected_providers actually
surfaces models in the picker rather than an empty group."""
assert "openai-codex" in config._PROVIDER_MODELS, (
"_PROVIDER_MODELS must include 'openai-codex' for the detection "
"fix to surface anything"
)
models = config._PROVIDER_MODELS["openai-codex"]
assert len(models) > 0, "openai-codex must have at least one static model"
# Sanity: contains codex-specific variants as well as shared gpt-5.x
ids = {m["id"] for m in models}
assert any("codex" in mid for mid in ids), (
"openai-codex group should expose at least one codex-specific model "
"(otherwise it's redundant with the openai group)"
)
def test_openai_codex_display_name_present():
"""The Codex group needs a human-readable label in _PROVIDER_DISPLAY,
otherwise the picker falls back to the raw provider id."""
assert config._PROVIDER_DISPLAY.get("openai-codex"), (
"_PROVIDER_DISPLAY must have a label for 'openai-codex'"
)

View File

@@ -0,0 +1,78 @@
"""
Regression test for #workspace-files: workspace file tree must stay
visible across REPEATED blank-page reloads (not just the first one).
Bug shape: PR #1182's ephemeral guard removed the stored session ID from
localStorage when it detected a 0-message session. That made the FIRST
refresh work (loadSession → loadDir → files render, then guard fires and
clears the key), but the SECOND refresh fell into the "no saved session"
boot path which never calls loadDir() — file tree went blank.
Fix: keep the session ID in localStorage. Every refresh runs the same
path:
loadSession() → loadDir() populates the workspace
→ ephemeral guard fires → S.session=null in memory only
→ workspace panel stays open with files visible
The session ID persisting in localStorage is harmless — server-side
``all_sessions()`` filters Untitled+0-message sessions so no phantom
sidebar entry appears, and ``newSession()`` overwrites the key when the
user actually creates a real session.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
def test_ephemeral_guard_does_not_remove_session_localstorage_key():
"""The empty-session guard block must NOT call
localStorage.removeItem('hermes-webui-session') — that's exactly what
breaks the second refresh."""
# Find the guard block (message_count===0 check)
guard_idx = BOOT_JS.find("(S.session.message_count||0) === 0")
assert guard_idx > 0, "Empty-session guard block not found in boot IIFE"
# The block runs until 'return;' that exits the IIFE early
block_end = BOOT_JS.find("return;", guard_idx)
assert block_end > guard_idx
block = BOOT_JS[guard_idx:block_end]
assert "removeItem('hermes-webui-session')" not in block, (
"The empty-session guard must NOT remove 'hermes-webui-session' from "
"localStorage. Removing it sends the next refresh into the no-saved-"
"session boot path which never calls loadDir(), leaving the workspace "
"file tree permanently blank (#workspace-files)."
)
assert 'removeItem("hermes-webui-session")' not in block, (
"Same as above (double-quoted form)."
)
def test_ephemeral_guard_still_clears_in_memory_session_state():
"""The guard MUST still clear ``S.session`` and ``S.messages`` in memory
so the user isn't locked into an empty conversation. Only the
localStorage cleanup is what was removed."""
guard_idx = BOOT_JS.find("(S.session.message_count||0) === 0")
block_end = BOOT_JS.find("return;", guard_idx)
block = BOOT_JS[guard_idx:block_end]
# Both in-memory clears must remain
assert re.search(r"S\.session\s*=\s*null", block), (
"Empty-session guard must still set S.session=null so the empty "
"scratch-pad is not surfaced as the active conversation"
)
assert re.search(r"S\.messages\s*=\s*\[\]", block), (
"Empty-session guard must still reset S.messages=[]"
)
def test_ephemeral_guard_still_restores_panel_pref():
"""PR #1187's panel-pref restore must still happen in the same block —
that's how the workspace panel stays visible on the empty-session
refresh path."""
guard_idx = BOOT_JS.find("(S.session.message_count||0) === 0")
block_end = BOOT_JS.find("return;", guard_idx)
block = BOOT_JS[guard_idx:block_end]
assert "hermes-webui-workspace-panel-pref" in block, (
"Empty-session guard must still read 'hermes-webui-workspace-panel-pref' "
"from localStorage to keep the panel open across refreshes (#1187)"
)