fix: batch v0.50.232 — fuzzy match, codex detection, workspace reload, timestamp sync (#1198)
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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){}
|
||||
|
||||
29
static/ui.js
29
static/ui.js
@@ -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>`;
|
||||
|
||||
492
tests/test_issue1144_session_time_sync.py
Normal file
492
tests/test_issue1144_session_time_sync.py
Normal 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"
|
||||
)
|
||||
153
tests/test_issue1188_fuzzy_match.py
Normal file
153
tests/test_issue1188_fuzzy_match.py
Normal 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
|
||||
92
tests/test_issue1189_openai_codex_detection.py
Normal file
92
tests/test_issue1189_openai_codex_detection.py
Normal 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'"
|
||||
)
|
||||
78
tests/test_workspace_files_persist_on_empty_reload.py
Normal file
78
tests/test_workspace_files_persist_on_empty_reload.py
Normal 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)"
|
||||
)
|
||||
Reference in New Issue
Block a user