mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-28 04:30:18 +00:00
Merge pull request #2944 from nesquena/release/stage-batch17
Release DG — stage-batch17 — 9-PR small-fix batch (v0.51.135)
This commit is contained in:
@@ -3,6 +3,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.135] — 2026-05-25 — Release DG (stage-batch17 — 9-PR small-fix batch)
|
||||
|
||||
### Added
|
||||
|
||||
- Added a proposed canonical session resolution RFC covering URL routes, query parameters, localStorage, sidebar rows, and compression-lineage IDs so future session-routing fixes have one review contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Browser session links that use the API-style `?session_id=<id>` query parameter now open the requested conversation instead of falling back to the last locally stored session.
|
||||
- Gateway status now treats existing messaging-session metadata as configured when `gateway.status` is unavailable, avoiding a misleading "Gateway not configured" warning for multi-container deployments with active gateway sessions.
|
||||
- Session sidebar Archive/Delete menu actions now repaint from local sidebar state immediately after the server confirms the mutation, instead of waiting for the full `/api/sessions` refresh before the row disappears.
|
||||
- Clarification dialogs now reserve transcript space while open or collapsed, so the question prompt no longer covers the assistant text needed to answer it.
|
||||
- Chat uploads now send the absolute server-side path for image attachments in the agent text context, restoring immediate tool access (e.g. `vision_analyze`) to files uploaded in the current turn.
|
||||
- Pending uploaded-file user turns no longer double-render when both the optimistic bubble and the server's pending-message hydration produce the same `[Attached files: ...]` suffix.
|
||||
|
||||
## [v0.51.134] — 2026-05-25 — Release DF (stage-batch16 — single-PR Windows path defaults)
|
||||
|
||||
### Fixed
|
||||
|
||||
+15
-1
@@ -174,10 +174,24 @@ def _gateway_root_pid_path() -> Path | None:
|
||||
Gateway runtime files are root-level singletons. A profile-scoped WebUI
|
||||
process may have HERMES_HOME=<root>/profiles/<name>, but gateway.pid,
|
||||
gateway.lock, and gateway_state.json still live under <root>.
|
||||
|
||||
When the root-level gateway.pid is absent (profile-scoped gateway
|
||||
deployments write it under <root>/profiles/<name>/), fall back to the
|
||||
active profile's directory so the gateway is detected correctly.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
return get_default_hermes_root() / _GATEWAY_PID_FILE
|
||||
root_pid = get_default_hermes_root() / _GATEWAY_PID_FILE
|
||||
if root_pid.exists():
|
||||
return root_pid
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
profile_pid = Path(get_active_hermes_home()) / _GATEWAY_PID_FILE
|
||||
if profile_pid.exists():
|
||||
return profile_pid
|
||||
except Exception:
|
||||
pass
|
||||
return root_pid
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
+1
-1
@@ -4754,7 +4754,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
configured = True
|
||||
else: # alive is None → gateway not configured / unavailable
|
||||
running = bool(identity_map)
|
||||
configured = False
|
||||
configured = bool(identity_map)
|
||||
|
||||
platforms_set: set[str] = set()
|
||||
for meta in identity_map.values():
|
||||
|
||||
@@ -27,6 +27,11 @@ does not change runtime behavior, maintainer policy, bot behavior, or CI gates.
|
||||
model-context reconstruction, compression, UI scene/cache, and sidebar metadata
|
||||
repairs. Start here for narrow fixes that keep the existing WebUI execution
|
||||
path.
|
||||
- [`docs/rfcs/canonical-session-resolution.md`](rfcs/canonical-session-resolution.md):
|
||||
proposed contract for resolving URL routes, query parameters, localStorage,
|
||||
sidebar rows, and compression-lineage IDs to one canonical visible session
|
||||
target. Start here for session routing, boot restore, stale parent, or
|
||||
compression-tip selection changes.
|
||||
- [`docs/rfcs/hermes-run-adapter-contract.md`](rfcs/hermes-run-adapter-contract.md):
|
||||
proposed event/control contract, runtime-state ownership matrix,
|
||||
acceptance-test catalog, and reversible migration gates for moving WebUI
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -46,5 +46,8 @@ First-time contributor RFCs should be discussed in an issue before opening a PR.
|
||||
— #2361 consistency rules for keeping transcript, model context, live streams,
|
||||
replay, compression, and session metadata coherent during active and recovered
|
||||
WebUI runs.
|
||||
- [`canonical-session-resolution.md`](canonical-session-resolution.md) — #2361
|
||||
focused contract for resolving URL, query parameter, localStorage, sidebar,
|
||||
and compression-lineage session IDs to one canonical visible chat target.
|
||||
- [`turn-journal.md`](turn-journal.md) — Crash-safe WebUI turn journal for
|
||||
recovering interrupted chat submissions.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Canonical Session Resolution Contract
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Author:** @ai-ag2026
|
||||
- **Created:** 2026-05-25
|
||||
- **Tracking issue:** [#2361](https://github.com/nesquena/hermes-webui/issues/2361)
|
||||
- **Related architecture:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925), [`webui-run-state-consistency-contract.md`](webui-run-state-consistency-contract.md)
|
||||
|
||||
## Problem
|
||||
|
||||
WebUI can reach the same conversation through several browser-facing entrypoints:
|
||||
|
||||
- a URL route such as `/session/<session_id>`,
|
||||
- a query parameter such as `?session=<session_id>` or `?session_id=<session_id>`,
|
||||
- the browser's `localStorage` active-session value,
|
||||
- sidebar rows built from `/api/sessions`,
|
||||
- direct session open actions from links, search, or imported session lists,
|
||||
- browser boot restore after reload, auth redirect, or PWA resume.
|
||||
|
||||
After automatic compression, those entrypoints can point at different rows in one
|
||||
logical conversation lineage. A pre-compression parent snapshot can remain a
|
||||
valid archived session while the user-facing conversation tip has moved to a
|
||||
newer continuation. If each caller resolves IDs independently, the UI can appear
|
||||
to lose the session, reopen an old one-message snapshot, duplicate sidebar rows,
|
||||
or prefer the wrong transcript even though durable data is still present.
|
||||
|
||||
This contract defines the expected resolution semantics for those entrypoints. It
|
||||
is intentionally narrower than the run adapter RFC: this is about choosing the
|
||||
correct visible session target, not moving execution ownership.
|
||||
|
||||
## Goals
|
||||
|
||||
- Define one canonical browser-facing resolution concept for sessions and
|
||||
compression lineage.
|
||||
- Make URL, query parameter, localStorage, sidebar, and direct-open behavior use
|
||||
the same mental model.
|
||||
- Preserve archived parent snapshots without letting them become the default
|
||||
active target when a continuation exists.
|
||||
- Give reviewers a small checklist for future session-routing, sidebar, and
|
||||
compression-lineage changes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not delete archived `pre_compression_snapshot` rows.
|
||||
- Do not merge or rewrite session files as part of this contract.
|
||||
- Do not replace state.db/session sidecar reconciliation.
|
||||
- Do not require a new backend endpoint before narrow frontend guards can land.
|
||||
- Do not change explicit history browsing when the user deliberately opens an
|
||||
archived snapshot as a record.
|
||||
|
||||
## Terms
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| Requested session ID | The ID supplied by route, query parameter, localStorage, sidebar click, or direct session open. |
|
||||
| Canonical visible session | The session row WebUI should display by default for normal chat navigation. |
|
||||
| `canonical_visible_session_id` | Proposed field/name for an API or helper output that identifies the canonical visible session. |
|
||||
| Compression snapshot | A preserved archived parent row with `pre_compression_snapshot` set. |
|
||||
| Continuation session | The active child/tip created after compression, usually represented by `continuation_session_id`, `_lineage_tip_id`, or newer lineage metadata. |
|
||||
| Lineage relation | Links such as `parent_session_id`, `_lineage_root_id`, `_lineage_tip_id`, and `_compression_segment_count` that connect rows belonging to one logical conversation. |
|
||||
|
||||
## Resolution Rules
|
||||
|
||||
1. **Directly valid non-snapshot IDs stay stable.** If the requested session ID
|
||||
exists and is not a `pre_compression_snapshot`, it should normally resolve to
|
||||
itself.
|
||||
2. **Snapshot parents defer to visible continuation tips.** If the requested
|
||||
session ID is a `pre_compression_snapshot` and the session list has a newer
|
||||
non-snapshot continuation in the same lineage, normal chat navigation should
|
||||
resolve to that continuation as the `canonical_visible_session_id`.
|
||||
3. **Explicit archive/history inspection remains possible.** A future UI affordance
|
||||
may intentionally open a snapshot as a historical record, but that should be a
|
||||
distinct mode from ordinary boot restore, URL restore, or sidebar continuation.
|
||||
4. **Local browser state is advisory.** `localStorage` may remember the last active
|
||||
ID, but browser boot restore must treat it as a requested session ID and still
|
||||
run canonical resolution before rendering.
|
||||
5. **Query aliases share the same resolver.** `?session=...`, `?session_id=...`,
|
||||
and `/session/...` should feed the same requested-ID path instead of carrying
|
||||
separate precedence rules.
|
||||
6. **Sidebar collapse and session loading agree.** The row chosen as the visible
|
||||
representative for a lineage should match the target opened by `loadSession()`
|
||||
for that lineage during ordinary navigation.
|
||||
7. **404 self-heal is separate from lineage resolution.** Missing/deleted sessions
|
||||
should still use the stale-route recovery path. A present archived parent with
|
||||
a live continuation is not a 404; it is a canonicalization problem.
|
||||
|
||||
## Entry Point Matrix
|
||||
|
||||
| Entry point | Input | Expected resolution |
|
||||
|---|---|---|
|
||||
| URL route | `/session/<id>` | Treat `<id>` as requested; resolve to canonical visible session before ordinary render. |
|
||||
| Query parameter | `?session=<id>` or `?session_id=<id>` | Same as URL route. Query spelling must not change the target semantics. |
|
||||
| localStorage | last active session ID | Advisory requested ID during browser boot restore; canonicalize before render. |
|
||||
| Sidebar click | visible row ID or lineage representative | Open the same canonical visible session that the row represents. |
|
||||
| Direct session open | programmatic call/search/import link | Use the shared requested-ID resolver unless the caller explicitly opts into archive inspection. |
|
||||
| Browser boot restore | URL and/or localStorage state after reload/auth/PWA resume | Prefer explicit URL/query input, then localStorage, then canonicalize the requested ID. |
|
||||
|
||||
## Review Checklist
|
||||
|
||||
For PRs that touch session routing, compression lineage, sidebar collapse, boot
|
||||
restore, direct session open, or URL parsing, answer:
|
||||
|
||||
- Which entrypoints provide the requested session ID?
|
||||
- Does the code path accept both route and query parameter forms where relevant?
|
||||
- Does localStorage go through the same canonicalization path as URL restore?
|
||||
- Can a `pre_compression_snapshot` become the default active chat when a
|
||||
non-snapshot `continuation_session_id` / `_lineage_tip_id` exists?
|
||||
- Do sidebar collapse and `loadSession()` pick the same visible representative?
|
||||
- Is missing-session 404 recovery kept distinct from present-but-archived lineage
|
||||
canonicalization?
|
||||
- What regression proves route, query parameter, localStorage, and sidebar paths
|
||||
agree for compressed lineage rows?
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. Document this proposed contract and link it from the public contract index.
|
||||
2. Keep narrow bugfixes small while referencing the relevant rule they preserve.
|
||||
3. Add shared frontend helper coverage for URL/query/localStorage/sidebar
|
||||
requested-ID inputs.
|
||||
4. If backend session APIs later expose `canonical_visible_session_id`, make the
|
||||
frontend resolver prefer the backend value while preserving client fallback for
|
||||
older WebUI servers.
|
||||
5. If #1925 moves execution/session ownership behind an adapter, carry this
|
||||
contract forward as an adapter-facing session-navigation invariant.
|
||||
+55
-1
@@ -433,7 +433,7 @@ async function send(){
|
||||
setComposerStatus('');
|
||||
|
||||
const uploadedNames=uploaded.map(u=>u.name||u);
|
||||
const uploadedPaths=uploaded.map(u=>u&&u.is_image?(u.name||u.filename||u):(u.path||u.name||u));
|
||||
const uploadedPaths=uploaded.map(u=>u&&u.path?u.path:(u&&u.name?u.name:(u&&u.filename?u.filename:u)));
|
||||
let msgText=text;
|
||||
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploadedPaths.join(', ')}`;
|
||||
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploadedPaths.join(', ')}]`;
|
||||
@@ -2677,12 +2677,63 @@ function _syncClarifyCollapseButton(card) {
|
||||
collapse.title = label;
|
||||
}
|
||||
|
||||
let _clarifyResizeListenerReady = false;
|
||||
|
||||
function _clarifyMessagesNearBottom(messages) {
|
||||
if (!messages) return false;
|
||||
return messages.scrollHeight - messages.scrollTop - messages.clientHeight < 150;
|
||||
}
|
||||
|
||||
function _syncClarifyTranscriptSpace(card, opts) {
|
||||
opts = opts || {};
|
||||
const messages = $("messages");
|
||||
if (!messages) return;
|
||||
const wasNearBottom = _clarifyMessagesNearBottom(messages);
|
||||
if (!card || !card.classList.contains("visible")) {
|
||||
messages.classList.remove("clarify-open");
|
||||
messages.classList.remove("clarify-collapsed");
|
||||
messages.style.removeProperty("--clarify-card-height");
|
||||
messages.style.removeProperty("--clarify-dock-height");
|
||||
if (wasNearBottom && typeof scrollToBottom === "function" && typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(scrollToBottom);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const collapsed = card.classList.contains("collapsed");
|
||||
messages.classList.add("clarify-open");
|
||||
messages.classList.toggle("clarify-collapsed", collapsed);
|
||||
const measure = () => {
|
||||
if (!card.classList.contains("visible")) return;
|
||||
const target = collapsed ? card : (card.querySelector(".clarify-inner") || card);
|
||||
const h = target && target.getBoundingClientRect().height;
|
||||
if (h > 0) {
|
||||
messages.style.setProperty(collapsed ? "--clarify-dock-height" : "--clarify-card-height", Math.ceil(h + 24) + "px");
|
||||
}
|
||||
if (wasNearBottom && typeof scrollToBottom === "function") scrollToBottom();
|
||||
};
|
||||
if (opts.immediate) measure();
|
||||
if (typeof requestAnimationFrame === "function") requestAnimationFrame(measure);
|
||||
setTimeout(measure, 420);
|
||||
}
|
||||
|
||||
function _ensureClarifyResizeListener() {
|
||||
if (_clarifyResizeListenerReady || typeof window === "undefined") return;
|
||||
_clarifyResizeListenerReady = true;
|
||||
window.addEventListener("resize", () => {
|
||||
const card = $("clarifyCard");
|
||||
if (card && card.classList.contains("visible")) {
|
||||
_syncClarifyTranscriptSpace(card, {immediate: true});
|
||||
}
|
||||
}, {passive: true});
|
||||
}
|
||||
|
||||
function toggleClarifyCardCollapsed(forceCollapsed) {
|
||||
const card = $("clarifyCard");
|
||||
if (!card) return;
|
||||
const collapsed = typeof forceCollapsed === "boolean" ? forceCollapsed : !card.classList.contains("collapsed");
|
||||
card.classList.toggle("collapsed", collapsed);
|
||||
_syncClarifyCollapseButton(card);
|
||||
_syncClarifyTranscriptSpace(card, {immediate: true});
|
||||
}
|
||||
|
||||
function _clearClarifyHideTimer() {
|
||||
@@ -2797,6 +2848,7 @@ function hideClarifyCard(force=false, reason="dismissed") {
|
||||
_clarifySessionId = null;
|
||||
_resetClarifyCardState();
|
||||
card.classList.remove("visible");
|
||||
_syncClarifyTranscriptSpace(null);
|
||||
if (typeof unlockComposerForClarify === "function") unlockComposerForClarify();
|
||||
$("clarifyQuestion").textContent = "";
|
||||
$("clarifyChoices").innerHTML = "";
|
||||
@@ -2911,8 +2963,10 @@ function showClarifyCard(pending) {
|
||||
lockComposerForClarify(question ? `Clarification needed: ${question}` : "Clarification needed");
|
||||
}
|
||||
_clarifySetControlsDisabled(false, false);
|
||||
_ensureClarifyResizeListener();
|
||||
card.classList.add("visible");
|
||||
_syncClarifyCollapseButton(card);
|
||||
_syncClarifyTranscriptSpace(card, {immediate: true});
|
||||
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
|
||||
// Move focus to clarify input synchronously (not in setTimeout) and
|
||||
// only if the user wasn't mid-type in the composer textarea.
|
||||
|
||||
+26
-4
@@ -647,6 +647,7 @@ async function loadSession(sid){
|
||||
if(!Array.isArray(messages)) return false;
|
||||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(session,messages):null;
|
||||
if(!pendingMsg) return false;
|
||||
if(messages.some(existing=>_sameTranscriptMessage(existing,pendingMsg))) return false;
|
||||
const liveAssistantIdx=messages.findIndex(m=>m&&m.role==='assistant'&&m._live);
|
||||
if(liveAssistantIdx>=0) messages.splice(liveAssistantIdx,0,pendingMsg);
|
||||
else messages.push(pendingMsg);
|
||||
@@ -1544,6 +1545,24 @@ function _sessionArchiveToast(response, session){
|
||||
function _sessionDeleteDescription(session){
|
||||
return session&&session.worktree_path?t('session_delete_worktree_desc'):t('session_delete_desc');
|
||||
}
|
||||
function _optimisticallyArchiveSessionInList(sid, archived){
|
||||
if(!sid||!Array.isArray(_allSessions)) return;
|
||||
let changed=false;
|
||||
_allSessions=_allSessions.map(s=>{
|
||||
if(!s||s.session_id!==sid) return s;
|
||||
changed=true;
|
||||
return {...s,archived:!!archived};
|
||||
});
|
||||
if(changed) renderSessionListFromCache();
|
||||
}
|
||||
function _optimisticallyRemoveSessionFromList(sid){
|
||||
if(!sid||!Array.isArray(_allSessions)) return;
|
||||
const before=_allSessions.length;
|
||||
_allSessions=_allSessions.filter(s=>!s||s.session_id!==sid);
|
||||
if(_selectedSessions&&_selectedSessions.has(sid)) _selectedSessions.delete(sid);
|
||||
if(typeof _dropStaleOptimisticSessionRow==='function') _dropStaleOptimisticSessionRow(sid);
|
||||
if(_allSessions.length!==before) renderSessionListFromCache();
|
||||
}
|
||||
|
||||
function _sessionIdFromLocation(){
|
||||
if(typeof window==='undefined'||!window.location) return null;
|
||||
@@ -1556,7 +1575,7 @@ function _sessionIdFromLocation(){
|
||||
}
|
||||
try{
|
||||
const qs=new URLSearchParams(window.location.search||'');
|
||||
return qs.get('session')||null;
|
||||
return qs.get('session')||qs.get('session_id')||null;
|
||||
}catch(_e){return null;}
|
||||
}
|
||||
function _sessionUrlForSid(sid){
|
||||
@@ -1866,9 +1885,10 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
closeSessionActionMenu();
|
||||
try{
|
||||
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})});
|
||||
_optimisticallyArchiveSessionInList(session.session_id,!session.archived);
|
||||
session.archived=!session.archived;
|
||||
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
|
||||
await renderSessionList();
|
||||
void renderSessionList();
|
||||
showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));
|
||||
}catch(err){showToast(t('session_archive_failed')+err.message);}
|
||||
}
|
||||
@@ -1882,9 +1902,10 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
closeSessionActionMenu();
|
||||
try{
|
||||
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:true})});
|
||||
_optimisticallyArchiveSessionInList(session.session_id,true);
|
||||
session.archived=true;
|
||||
if(S.session&&S.session.session_id===session.session_id) S.session.archived=true;
|
||||
await renderSessionList();
|
||||
void renderSessionList();
|
||||
showToast(t('session_hidden'));
|
||||
}catch(err){showToast(t('session_archive_failed')+err.message);}
|
||||
}
|
||||
@@ -3874,6 +3895,7 @@ async function deleteSession(sid){
|
||||
let response=null;
|
||||
try{
|
||||
response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||||
_optimisticallyRemoveSessionFromList(sid);
|
||||
_clearHandoffStorageForSession(sid);
|
||||
}catch(e){setStatus(`Delete failed: ${e.message}`);return;}
|
||||
if(S.session&&S.session.session_id===sid){
|
||||
@@ -3894,7 +3916,7 @@ async function deleteSession(sid){
|
||||
}
|
||||
}
|
||||
showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted'));
|
||||
await renderSessionList();
|
||||
void renderSessionList();
|
||||
}
|
||||
|
||||
// ── Project helpers ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -984,6 +984,8 @@
|
||||
.messages.terminal-open{padding-bottom:var(--terminal-card-height,320px);scroll-padding-bottom:var(--terminal-card-height,320px);transition:padding-bottom .26s cubic-bezier(.2,.8,.2,1);}
|
||||
.messages.terminal-collapsed{padding-bottom:var(--terminal-dock-height,72px);scroll-padding-bottom:var(--terminal-dock-height,72px);transition:padding-bottom .22s cubic-bezier(.2,.8,.2,1);}
|
||||
.messages.handoff-dock-visible{padding-bottom:var(--handoff-dock-height,72px);scroll-padding-bottom:var(--handoff-dock-height,72px);transition:padding-bottom .22s cubic-bezier(.2,.8,.2,1);}
|
||||
.messages.clarify-open{padding-bottom:var(--clarify-card-height,320px);scroll-padding-bottom:var(--clarify-card-height,320px);transition:padding-bottom .22s cubic-bezier(.2,.8,.2,1);}
|
||||
.messages.clarify-collapsed{padding-bottom:var(--clarify-dock-height,72px);scroll-padding-bottom:var(--clarify-dock-height,72px);}
|
||||
.messages.terminal-expanding-from-dock{transition:none!important;}
|
||||
.queue-card-inner{background:var(--surface);border:1px solid var(--border);border-bottom:none;border-radius:14px 14px 0 0;contain:paint;transform:translateY(100%);opacity:0;transition:transform .35s cubic-bezier(.32,.72,.16,1),opacity .2s ease;overflow:hidden;max-height:240px;overflow-y:auto;padding-bottom:4px;}
|
||||
.queue-card.visible .queue-card-inner{transform:translateY(0);opacity:1;}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Regression coverage for _gateway_root_pid_path() profile-scoped fallback.
|
||||
|
||||
Before the fix, _gateway_root_pid_path() unconditionally returned
|
||||
<hermes_root>/gateway.pid. Profile-scoped gateways (running via
|
||||
``gateway run --profile <name>`` or with ``active_profile`` set) write
|
||||
their PID file under <hermes_root>/profiles/<name>/gateway.pid instead of
|
||||
the root, so the root-level file never existed. The WebUI's
|
||||
build_agent_health_payload() therefore always received a non-existent
|
||||
pid_path, fell through to the stale root-level gateway_state.json, and
|
||||
returned alive=None — causing the cron page to display "Gateway not
|
||||
configured" even though the gateway was running.
|
||||
|
||||
Fix: when the root-level gateway.pid is absent, _gateway_root_pid_path()
|
||||
now falls back to the active profile's directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("hermes_constants", reason="hermes-agent not installed")
|
||||
|
||||
|
||||
def _call(monkeypatch, root: Path, profile_dir: Path | None = None) -> Path | None:
|
||||
"""Call _gateway_root_pid_path() with mocked filesystem roots."""
|
||||
import hermes_constants
|
||||
import api.profiles as profiles
|
||||
|
||||
monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: root)
|
||||
if profile_dir is not None:
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: str(profile_dir))
|
||||
|
||||
from api.agent_health import _gateway_root_pid_path
|
||||
return _gateway_root_pid_path()
|
||||
|
||||
|
||||
# ── core behaviour ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_returns_root_pid_when_root_level_file_exists(tmp_path, monkeypatch):
|
||||
"""Root-level gateway.pid present → return it (original behaviour unchanged)."""
|
||||
root = tmp_path / "hermes"
|
||||
root.mkdir()
|
||||
root_pid = root / "gateway.pid"
|
||||
root_pid.write_text("1234")
|
||||
profile_dir = root / "profiles" / "other"
|
||||
profile_dir.mkdir(parents=True)
|
||||
(profile_dir / "gateway.pid").write_text("9999")
|
||||
|
||||
result = _call(monkeypatch, root=root, profile_dir=profile_dir)
|
||||
assert result == root_pid
|
||||
|
||||
|
||||
def test_falls_back_to_profile_pid_when_root_absent(tmp_path, monkeypatch):
|
||||
"""Root gateway.pid absent + profile-level exists → return profile path."""
|
||||
root = tmp_path / "hermes"
|
||||
root.mkdir()
|
||||
# root-level gateway.pid intentionally not created
|
||||
profile_dir = root / "profiles" / "safeline"
|
||||
profile_dir.mkdir(parents=True)
|
||||
profile_pid = profile_dir / "gateway.pid"
|
||||
profile_pid.write_text("5678")
|
||||
|
||||
result = _call(monkeypatch, root=root, profile_dir=profile_dir)
|
||||
assert result == profile_pid
|
||||
|
||||
|
||||
def test_returns_root_path_when_neither_pid_exists(tmp_path, monkeypatch):
|
||||
"""Neither root nor profile gateway.pid exists → return root path (graceful)."""
|
||||
root = tmp_path / "hermes"
|
||||
root.mkdir()
|
||||
profile_dir = root / "profiles" / "empty"
|
||||
profile_dir.mkdir(parents=True)
|
||||
# no gateway.pid created anywhere
|
||||
|
||||
result = _call(monkeypatch, root=root, profile_dir=profile_dir)
|
||||
assert result == root / "gateway.pid"
|
||||
|
||||
|
||||
def test_returns_root_path_when_profile_lookup_raises(tmp_path, monkeypatch):
|
||||
"""get_active_hermes_home() raising must be caught; root path returned."""
|
||||
root = tmp_path / "hermes"
|
||||
root.mkdir()
|
||||
|
||||
import hermes_constants
|
||||
import api.profiles as profiles
|
||||
|
||||
monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: root)
|
||||
|
||||
def _raise():
|
||||
raise RuntimeError("profile resolution failed")
|
||||
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", _raise)
|
||||
|
||||
from api.agent_health import _gateway_root_pid_path
|
||||
result = _gateway_root_pid_path()
|
||||
assert result == root / "gateway.pid"
|
||||
|
||||
|
||||
def test_root_takes_priority_over_profile_when_both_exist(tmp_path, monkeypatch):
|
||||
"""Root gateway.pid present even when profile pid also exists → root wins."""
|
||||
root = tmp_path / "hermes"
|
||||
root.mkdir()
|
||||
root_pid = root / "gateway.pid"
|
||||
root_pid.write_text("1111")
|
||||
profile_dir = root / "profiles" / "safeline"
|
||||
profile_dir.mkdir(parents=True)
|
||||
(profile_dir / "gateway.pid").write_text("2222")
|
||||
|
||||
result = _call(monkeypatch, root=root, profile_dir=profile_dir)
|
||||
assert result == root_pid
|
||||
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RFC = ROOT / "docs" / "rfcs" / "canonical-session-resolution.md"
|
||||
RFC_INDEX = ROOT / "docs" / "rfcs" / "README.md"
|
||||
CONTRACTS = ROOT / "docs" / "CONTRACTS.md"
|
||||
|
||||
|
||||
def test_canonical_session_resolution_rfc_is_indexed():
|
||||
assert RFC.exists(), "canonical session resolution RFC must exist"
|
||||
|
||||
rel = "docs/rfcs/canonical-session-resolution.md"
|
||||
rfc_index = RFC_INDEX.read_text(encoding="utf-8")
|
||||
contracts = CONTRACTS.read_text(encoding="utf-8")
|
||||
|
||||
assert "canonical-session-resolution.md" in rfc_index
|
||||
assert rel in contracts
|
||||
|
||||
|
||||
def test_canonical_session_resolution_contract_names_entrypoints_and_outputs():
|
||||
text = RFC.read_text(encoding="utf-8")
|
||||
|
||||
required_terms = [
|
||||
"URL route",
|
||||
"query parameter",
|
||||
"localStorage",
|
||||
"sidebar",
|
||||
"pre_compression_snapshot",
|
||||
"canonical_visible_session_id",
|
||||
"continuation_session_id",
|
||||
"parent_session_id",
|
||||
"direct session open",
|
||||
"browser boot restore",
|
||||
]
|
||||
|
||||
missing = [term for term in required_terms if term not in text]
|
||||
assert missing == []
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Regression coverage for WebUI chat upload path handoff."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MESSAGES_JS = ROOT / "static" / "messages.js"
|
||||
|
||||
|
||||
def test_image_uploads_use_server_path_in_attached_files_context():
|
||||
"""The agent text context must include real uploaded paths for images.
|
||||
|
||||
/api/upload returns an absolute attachment path. The browser also sends the
|
||||
structured attachment payload to /api/chat/start, but text/tool-mode agents
|
||||
still rely on the literal ``[Attached files: ...]`` suffix. Images must not
|
||||
be downgraded to bare filenames there, otherwise tools like vision_analyze
|
||||
cannot open the uploaded file immediately.
|
||||
"""
|
||||
src = MESSAGES_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert "uploadedPaths=uploaded.map(u=>u&&u.is_image?" not in src
|
||||
assert "uploadedPaths=uploaded.map(u=>u&&u.path?u.path" in src
|
||||
@@ -124,7 +124,8 @@ def test_gateway_status_running_true_and_platforms_when_agent_health_alive_and_s
|
||||
|
||||
def test_gateway_status_alive_none_falls_back_to_identity_map_heuristic(monkeypatch):
|
||||
"""When alive=None (not configured) but sessions exist, running reflects identity_map.
|
||||
configured=false tells the frontend to show 'not configured' state."""
|
||||
configured=true because sessions metadata proves a gateway is configured
|
||||
even if gateway.status cannot be imported in this WebUI process."""
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -144,8 +145,8 @@ def test_gateway_status_alive_none_falls_back_to_identity_map_heuristic(monkeypa
|
||||
result = handler.get_json()
|
||||
# Fallback to identity_map: sessions exist → running=true
|
||||
assert result["running"] is True
|
||||
# But configured=false because alive was None (no gateway metadata)
|
||||
assert result["configured"] is False
|
||||
# Existing gateway sessions prove the gateway has been configured.
|
||||
assert result["configured"] is True
|
||||
|
||||
|
||||
def test_gateway_status_handles_corrupted_sessions_json(monkeypatch):
|
||||
@@ -244,4 +245,4 @@ def test_gateway_status_last_active_empty_when_alive_and_no_sessions_path(monkey
|
||||
assert result["configured"] is True
|
||||
# In test context, sessions_path won't exist (no real filesystem),
|
||||
# so last_active must be empty.
|
||||
assert result["last_active"] == ""
|
||||
assert result["last_active"] == ""
|
||||
|
||||
@@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -96,6 +97,72 @@ def _read_session(db_path: Path, session_id: str):
|
||||
conn.close()
|
||||
|
||||
|
||||
def _make_message_state_db(path: Path, session_id: str, message_count: int, label: str):
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, title TEXT, model TEXT, started_at REAL, message_count INTEGER)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, content TEXT, timestamp REAL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, title, model, started_at, message_count) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(session_id, "webui", label, "test-model", 1000.0, message_count),
|
||||
)
|
||||
for idx in range(message_count):
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
"user" if idx % 2 == 0 else "assistant",
|
||||
f"{label} message {idx + 1}",
|
||||
1000.0 + idx,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def two_profile_message_homes(tmp_path, monkeypatch):
|
||||
"""Minimal multi-profile state.db homes for metadata-only read-path tests."""
|
||||
import api.config as config
|
||||
import api.models as models_mod
|
||||
import api.profiles as profiles_mod
|
||||
import api.routes as routes_mod
|
||||
|
||||
hiyuki_home = tmp_path / "hiyuki"
|
||||
maiko_home = tmp_path / "maiko"
|
||||
session_dir = tmp_path / "webui-state" / "sessions"
|
||||
for home in (hiyuki_home, maiko_home, session_dir):
|
||||
home.mkdir(parents=True)
|
||||
|
||||
sid = "metadata_profile_routing"
|
||||
_make_message_state_db(hiyuki_home / "state.db", sid, 1, "hiyuki")
|
||||
_make_message_state_db(maiko_home / "state.db", sid, 3, "maiko")
|
||||
|
||||
def fake_profile_home(name):
|
||||
if name == "maiko":
|
||||
return maiko_home
|
||||
if name == "hiyuki" or name in (None, "", "default"):
|
||||
return hiyuki_home
|
||||
raise LookupError(name)
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "get_hermes_home_for_profile", fake_profile_home)
|
||||
monkeypatch.setattr(profiles_mod, "get_active_hermes_home", lambda: hiyuki_home)
|
||||
monkeypatch.setattr(models_mod, "_active_state_db_path", lambda: hiyuki_home / "state.db")
|
||||
monkeypatch.setattr(config, "STATE_DIR", tmp_path / "webui-state", raising=False)
|
||||
monkeypatch.setattr(config, "SESSION_DIR", session_dir, raising=False)
|
||||
monkeypatch.setattr(config, "SESSION_INDEX_FILE", session_dir / "_index.json", raising=False)
|
||||
monkeypatch.setattr(models_mod, "SESSION_DIR", session_dir, raising=False)
|
||||
monkeypatch.setattr(models_mod, "SESSION_INDEX_FILE", session_dir / "_index.json", raising=False)
|
||||
monkeypatch.setattr(routes_mod, "_active_state_db_path", lambda: hiyuki_home / "state.db", raising=False)
|
||||
|
||||
return {"sid": sid, "hiyuki": hiyuki_home, "maiko": maiko_home, "session_dir": session_dir}
|
||||
|
||||
|
||||
def test_get_state_db_honors_explicit_profile_kwarg(two_profile_homes):
|
||||
"""_get_state_db(profile='maiko') resolves to maiko's home, NOT
|
||||
the active profile (hiyuki)."""
|
||||
@@ -182,6 +249,101 @@ def test_sync_session_usage_without_profile_kwarg_uses_active(two_profile_homes)
|
||||
"sync_session_usage() without profile= regressed: did not write to active profile's state.db"
|
||||
|
||||
|
||||
def test_metadata_only_summary_reads_explicit_profile_state_db(two_profile_message_homes):
|
||||
"""Metadata-only state.db reads must honor explicit profile=.
|
||||
|
||||
This pins the read-path equivalent of #2762/#2827: if the helper drops the
|
||||
profile kwarg or stops forwarding it to get_state_db_session_messages(), it
|
||||
falls back to the active profile (hiyuki) and reports the wrong count.
|
||||
"""
|
||||
from api.routes import _metadata_only_message_summary
|
||||
|
||||
sid = two_profile_message_homes["sid"]
|
||||
|
||||
maiko_summary = _metadata_only_message_summary(sid, profile="maiko")
|
||||
hiyuki_summary = _metadata_only_message_summary(sid)
|
||||
|
||||
assert maiko_summary["message_count"] == 3
|
||||
assert hiyuki_summary["message_count"] == 1
|
||||
|
||||
|
||||
def test_metadata_only_summary_honors_profile_from_background_thread(two_profile_message_homes):
|
||||
"""Explicit profile= must work even when TLS active-profile context is absent."""
|
||||
from api.routes import _metadata_only_message_summary
|
||||
|
||||
sid = two_profile_message_homes["sid"]
|
||||
result = {}
|
||||
|
||||
def run():
|
||||
result["summary"] = _metadata_only_message_summary(sid, profile="maiko")
|
||||
|
||||
worker = threading.Thread(target=run)
|
||||
worker.start()
|
||||
worker.join(timeout=2)
|
||||
|
||||
assert not worker.is_alive()
|
||||
assert result["summary"]["message_count"] == 3
|
||||
|
||||
|
||||
def test_api_session_metadata_only_passes_session_profile_to_summary(
|
||||
two_profile_message_homes, monkeypatch
|
||||
):
|
||||
"""GET /api/session?messages=0 must pass the loaded session profile onward.
|
||||
|
||||
If the call site accidentally invokes _metadata_only_message_summary(sid)
|
||||
without profile=s.profile, this test reports the active hiyuki count (1)
|
||||
instead of maiko's count (3).
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
from io import BytesIO
|
||||
|
||||
import api.models as models_mod
|
||||
import api.routes as routes_mod
|
||||
|
||||
sid = two_profile_message_homes["sid"]
|
||||
session = models_mod.Session(
|
||||
session_id=sid,
|
||||
title="Metadata profile routing",
|
||||
workspace=str(two_profile_message_homes["session_dir"]),
|
||||
model="test-model",
|
||||
profile="maiko",
|
||||
messages=[
|
||||
{"role": "user", "content": "maiko message 1", "timestamp": 1000.0}
|
||||
],
|
||||
created_at=1000.0,
|
||||
updated_at=1001.0,
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
monkeypatch.setattr(routes_mod, "_lookup_cli_session_metadata", lambda _sid: {})
|
||||
|
||||
class Handler:
|
||||
path = f"/api/session?session_id={sid}&messages=0&resolve_model=0"
|
||||
headers = {}
|
||||
client_address = ("127.0.0.1", 12345)
|
||||
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.wfile = BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, *_args):
|
||||
pass
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def log_message(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
handler = Handler()
|
||||
routes_mod.handle_get(handler, urlparse(handler.path))
|
||||
|
||||
assert handler.status == 200
|
||||
assert b'"message_count": 3' in handler.wfile.getvalue()
|
||||
|
||||
|
||||
def test_unknown_explicit_profile_returns_none_not_falls_back(two_profile_homes):
|
||||
"""Copilot review of PR #2827: when ``profile`` is explicit and
|
||||
resolution fails (e.g. typoed profile name, IO error), the
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _compact(text: str) -> str:
|
||||
return "".join(text.split())
|
||||
|
||||
|
||||
def test_clarify_card_reserves_transcript_space():
|
||||
compact_css = _compact(STYLE_CSS)
|
||||
|
||||
assert ".messages.clarify-open" in STYLE_CSS
|
||||
assert "padding-bottom:var(--clarify-card-height,320px)" in compact_css
|
||||
assert "scroll-padding-bottom:var(--clarify-card-height,320px)" in compact_css
|
||||
|
||||
|
||||
def test_clarify_collapse_uses_smaller_transcript_space():
|
||||
compact_css = _compact(STYLE_CSS)
|
||||
compact_js = _compact(MESSAGES_JS)
|
||||
|
||||
assert ".messages.clarify-collapsed" in STYLE_CSS
|
||||
assert "padding-bottom:var(--clarify-dock-height,72px)" in compact_css
|
||||
assert 'classList.toggle("clarify-collapsed",collapsed)' in compact_js
|
||||
assert "--clarify-dock-height" in MESSAGES_JS
|
||||
|
||||
|
||||
def test_clarify_show_hide_toggle_messages_padding_classes():
|
||||
compact_js = _compact(MESSAGES_JS)
|
||||
|
||||
assert "_syncClarifyTranscriptSpace(card,{immediate:true})" in compact_js
|
||||
assert "_syncClarifyTranscriptSpace(null)" in compact_js
|
||||
assert 'classList.add("clarify-open")' in MESSAGES_JS
|
||||
assert 'classList.remove("clarify-open")' in MESSAGES_JS
|
||||
assert "--clarify-card-height" in MESSAGES_JS
|
||||
|
||||
|
||||
def test_clarify_padding_remeasures_on_resize():
|
||||
compact_js = _compact(MESSAGES_JS)
|
||||
|
||||
assert "function_ensureClarifyResizeListener()" in compact_js
|
||||
assert 'window.addEventListener("resize"' in MESSAGES_JS
|
||||
assert "_ensureClarifyResizeListener();" in MESSAGES_JS
|
||||
@@ -650,6 +650,25 @@ def test_loadSession_inflight_merges_tail_with_persisted_transcript(cleanup_test
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_browser_session_url_accepts_api_session_id_param(cleanup_test_sessions):
|
||||
"""External links using ?session_id=... should open that session in the browser.
|
||||
|
||||
The API endpoint uses `session_id`, while the browser URL historically used
|
||||
`session`/`/session/<id>`. Auth/cookie bridges and external callers can
|
||||
legitimately produce `/?session_id=<sid>`; ignoring it falls back to stale
|
||||
localStorage and renders the wrong or empty conversation.
|
||||
"""
|
||||
src = (REPO_ROOT / "static/sessions.js").read_text()
|
||||
start = src.find("function _sessionIdFromLocation")
|
||||
assert start >= 0, "session URL parser not found"
|
||||
end = src.find("function _sessionUrlForSid", start)
|
||||
assert end > start, "session URL parser block end not found"
|
||||
block = src[start:end]
|
||||
assert "qs.get('session')" in block or 'qs.get("session")' in block
|
||||
assert "qs.get('session_id')" in block or 'qs.get("session_id")' in block
|
||||
|
||||
|
||||
def test_inflight_merge_dedupes_uploaded_user_message(cleanup_test_sessions):
|
||||
"""Uploaded-file turns render optimistically before the server stores the
|
||||
final pending text with an `[Attached files: ...]` suffix. The INFLIGHT
|
||||
@@ -665,6 +684,12 @@ def test_inflight_merge_dedupes_uploaded_user_message(cleanup_test_sessions):
|
||||
assert "role==='user'" in src, (
|
||||
"attached-files normalization should be limited to user turns"
|
||||
)
|
||||
pending_idx = src.find("function _mergePendingSessionMessage")
|
||||
assert pending_idx >= 0, "pending session merge helper not found"
|
||||
pending_block = src[pending_idx:pending_idx+500]
|
||||
assert "_sameTranscriptMessage(existing,pendingMsg)" in pending_block, (
|
||||
"pending-user merge should reuse transcript identity dedupe before inserting the server pending message"
|
||||
)
|
||||
|
||||
|
||||
def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_cards(cleanup_test_sessions):
|
||||
|
||||
@@ -29,3 +29,29 @@ def test_session_list_refresh_does_not_close_open_conversation_actions():
|
||||
assert "if(_renamingSid) return;" in body
|
||||
assert "if(_sessionActionMenu) return;" in body
|
||||
assert body.index("if(_sessionActionMenu) return;") < body.index("closeSessionActionMenu();")
|
||||
|
||||
|
||||
def test_archive_action_repaints_sidebar_before_full_refresh():
|
||||
"""Archive should hide the row from cached sidebar state before /api/sessions returns."""
|
||||
body = _function_block(SESSIONS_JS, "_openSessionActionMenu")
|
||||
|
||||
api_call = "const response=await api('/api/session/archive'"
|
||||
optimistic = "_optimisticallyArchiveSessionInList(session.session_id,!session.archived);"
|
||||
full_refresh = "void renderSessionList();"
|
||||
|
||||
assert optimistic in body
|
||||
assert body.index(api_call) < body.index(optimistic) < body.index(full_refresh)
|
||||
|
||||
|
||||
def test_delete_action_repaints_sidebar_before_loading_remaining_sessions():
|
||||
"""Delete should remove the row locally before loading replacement session data."""
|
||||
body = _function_block(SESSIONS_JS, "deleteSession")
|
||||
|
||||
api_call = "response=await api('/api/session/delete'"
|
||||
optimistic = "_optimisticallyRemoveSessionFromList(sid);"
|
||||
remaining_fetch = "const remaining=await api('/api/sessions');"
|
||||
full_refresh = "void renderSessionList();"
|
||||
|
||||
assert optimistic in body
|
||||
assert body.index(api_call) < body.index(optimistic) < body.index(full_refresh)
|
||||
assert body.index(optimistic) < body.index(remaining_fetch)
|
||||
|
||||
Reference in New Issue
Block a user