Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 101 additions & 7 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7762,13 +7762,19 @@ async def cancel_oauth_session(



def _session_latest_descendant(session_id: str):
def _profile_for_session_db_lookup(profile: Optional[str]) -> Optional[str]:
"""Translate dashboard profile sentinels into session-DB lookup scope."""
return None if not profile or profile == "current" else profile


def _session_latest_descendant(session_id: str, profile: Optional[str] = None):
"""Resolve a session id to the newest child leaf session.

/model may create child sessions. Dashboard refresh should continue the
newest child instead of reopening the old parent.
newest child instead of reopening the old parent. When ``profile`` is
supplied, resolve inside that profile's state DB; otherwise preserve the
legacy dashboard-profile lookup.
"""
from hermes_state import SessionDB

def row_get(row, key, index):
if isinstance(row, dict):
Expand All @@ -7781,7 +7787,7 @@ def row_get(row, key, index):
except Exception:
return None

db = SessionDB()
db = _open_session_db_for_profile(_profile_for_session_db_lookup(profile))
try:
sid = db.resolve_session_id(session_id)
if not sid or not db.get_session(sid):
Expand Down Expand Up @@ -7839,6 +7845,82 @@ def started(row):
db.close()


def _infer_profile_for_resume_session(session_id: Optional[str]) -> Optional[str]:
"""Infer the owning named profile for a dashboard-chat resume target.

Desktop deep links and older session rows can carry ``resume=<id>`` without
``profile=<name>``. In a multi-profile install that would spawn the PTY
under the dashboard/current HERMES_HOME even when the session lives in a
different profile, so tools/skills/config resolve from the wrong profile. Look
up the session id across local profile DBs and return the unique owner,
including the built-in ``default`` profile. Ambiguous/no matches preserve
legacy unscoped behavior rather than guessing.
"""
sid = (session_id or "").strip()
if not sid:
return None

from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod

try:
infos = profiles_mod.list_profiles()
targets: list[tuple[str, Path]] = [(info.name, Path(info.path)) for info in infos]
except Exception:
_log.debug("resume profile inference: profile inventory failed", exc_info=True)
targets = []

if not any(name == "default" for name, _home in targets):
try:
targets.insert(0, ("default", profiles_mod.get_profile_dir("default")))
except Exception:
pass

matches: list[str] = []
seen: set[str] = set()
for name, home in targets:
if name in seen:
continue
seen.add(name)
db_path = Path(home) / "state.db"
if not db_path.exists():
continue
try:
db = SessionDB(db_path=db_path, read_only=True)
except Exception:
_log.debug(
"resume profile inference: could not open %s for %s",
db_path,
name,
exc_info=True,
)
continue
try:
resolved = db.resolve_session_id(sid)
if resolved and db.get_session(resolved):
matches.append(name)
except Exception:
_log.debug(
"resume profile inference: lookup failed for %s in %s",
sid,
name,
exc_info=True,
)
finally:
db.close()

unique = list(dict.fromkeys(matches))
if len(unique) == 1:
return unique[0]
if len(unique) > 1:
_log.warning(
"resume profile inference ambiguous for session %s: %s",
sid,
unique,
)
return None


# CRITICAL — every literal-path route below MUST be declared BEFORE the
# templated ``/api/sessions/{session_id}`` family that follows. FastAPI/
# Starlette match routes in registration order, and the ``{session_id}``
Expand Down Expand Up @@ -8010,8 +8092,11 @@ async def get_session_detail(session_id: str, profile: Optional[str] = None):


@app.get("/api/sessions/{session_id}/latest-descendant")
async def get_session_latest_descendant(session_id: str):
latest, path = _session_latest_descendant(session_id)
async def get_session_latest_descendant(
session_id: str,
profile: Optional[str] = None,
):
latest, path = _session_latest_descendant(session_id, profile=profile)
if not latest:
raise HTTPException(status_code=404, detail="Session not found")
return {
Expand All @@ -8021,6 +8106,12 @@ async def get_session_latest_descendant(session_id: str):
"changed": bool(path and latest != path[0]),
}


@app.get("/api/sessions/{session_id}/owner-profile")
async def get_session_owner_profile(session_id: str):
return {"profile": _infer_profile_for_resume_session(session_id)}


@app.get("/api/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, profile: Optional[str] = None):
db = _open_session_db_for_profile(profile)
Expand Down Expand Up @@ -12665,7 +12756,7 @@ def _resolve_chat_argv(
env["HERMES_HOME"] = str(profile_dir)

if resume:
latest_resume, _latest_path = _session_latest_descendant(resume)
latest_resume, _latest_path = _session_latest_descendant(resume, profile=profile)
if latest_resume:
resume = latest_resume
env["HERMES_TUI_RESUME"] = resume
Expand Down Expand Up @@ -13502,6 +13593,9 @@ async def pty_ws(ws: WebSocket) -> None:
elif not resume:
resume = _read_active_session_file(active_session_file)

if resume and not profile:
profile = _infer_profile_for_resume_session(resume)

resolve_kwargs = {
"resume": resume,
"sidecar_url": sidecar_url,
Expand Down
131 changes: 131 additions & 0 deletions tests/hermes_cli/test_web_server_profile_unification.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,137 @@ class _FakeProc:


class TestProfileScopedChatPty:
class _OneFrameBridge:
@classmethod
def spawn(cls, *args, **kwargs):
return cls()

def __init__(self):
self._sent = False

def read(self, timeout):
if not self._sent:
self._sent = True
return b"ready"
return None

def resize(self, *, cols, rows):
pass

def write(self, raw):
pass

def close(self):
pass

@staticmethod
def _write_session(home, session_id, **kwargs):
from hermes_state import SessionDB

db = SessionDB(db_path=home / "state.db")
try:
db.create_session(session_id=session_id, source=kwargs.pop("source", "telegram"), **kwargs)
finally:
db.close()

def test_pty_infers_profile_from_unscoped_resume_session(
self, isolated_profiles, monkeypatch
):
import hermes_cli.web_server as web_server
from starlette.testclient import TestClient

self._write_session(isolated_profiles["worker_beta"], "worker-session-123")

captured = {}

async def fake_resolve(**kwargs):
captured.update(kwargs)
return (["fake-hermes-tui"], None, None)

monkeypatch.setattr(web_server, "_resolve_chat_argv_async", fake_resolve)
monkeypatch.setattr(web_server.PtyBridge, "spawn", self._OneFrameBridge.spawn)
web_server.app.state.pty_active_session_files = {}

client = TestClient(web_server.app)
with client.websocket_connect(
f"/api/pty?token={web_server._SESSION_TOKEN}&resume=worker-session-123&channel=infer-profile"
) as conn:
assert conn.receive_bytes() == b"ready"

assert captured["resume"] == "worker-session-123"
assert captured["profile"] == "worker_beta"

def test_latest_descendant_reads_requested_profile(self, client, isolated_profiles):
self._write_session(isolated_profiles["worker_beta"], "worker-parent")
self._write_session(
isolated_profiles["worker_beta"],
"worker-child",
parent_session_id="worker-parent",
)

resp = client.get(
"/api/sessions/worker-parent/latest-descendant",
params={"profile": "worker_beta"},
)

assert resp.status_code == 200
assert resp.json()["session_id"] == "worker-child"

def test_owner_profile_endpoint_resolves_named_profile(
self, client, isolated_profiles
):
self._write_session(isolated_profiles["worker_beta"], "worker-session-123")

resp = client.get("/api/sessions/worker-session-123/owner-profile")

assert resp.status_code == 200
assert resp.json() == {"profile": "worker_beta"}

def test_owner_profile_endpoint_resolves_default_profile(
self, client, isolated_profiles
):
self._write_session(isolated_profiles["default"], "default-session-123")

resp = client.get("/api/sessions/default-session-123/owner-profile")

assert resp.status_code == 200
assert resp.json() == {"profile": "default"}

def test_resume_profile_inference_keeps_legacy_on_missing_or_ambiguous(
self, isolated_profiles
):
import hermes_cli.web_server as web_server

assert web_server._infer_profile_for_resume_session("missing-session") is None

worker_gamma = isolated_profiles["default"] / "profiles" / "worker_gamma"
worker_gamma.mkdir(parents=True, exist_ok=True)
self._write_session(isolated_profiles["worker_beta"], "ambiguous-session")
self._write_session(worker_gamma, "ambiguous-session")

assert web_server._infer_profile_for_resume_session("ambiguous-session") is None

def test_chat_argv_profile_current_keeps_legacy_resume_lookup(
self, isolated_profiles, monkeypatch
):
import hermes_cli.web_server as web_server

self._write_session(isolated_profiles["default"], "current-session")
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)

_argv, _cwd, env = web_server._resolve_chat_argv(
resume="current-session",
profile="current",
)

assert env is not None
assert env["HERMES_TUI_RESUME"] == "current-session"
assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"])

def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server

Expand Down
16 changes: 12 additions & 4 deletions web/src/components/ChatSessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ function rowLabel(session: SessionInfo, untitled: string): string {
return untitled;
}

function ownerProfileParam(session: SessionInfo): string {
const profile = session.profile?.trim();
return profile;
}

export function ChatSessionList({
activeSessionId,
profile,
Expand Down Expand Up @@ -113,13 +118,16 @@ export function ChatSessionList({
// Picking a row sets `/chat?resume=<id>`. Re-picking the row already in
// the terminal is a no-op (avoids a needless PTY teardown).
const pick = useCallback(
(id: string) => {
(session: SessionInfo) => {
onPicked?.();
if (id === activeSessionId) return;
if (session.id === activeSessionId) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("resume", id);
next.set("resume", session.id);
const owner = ownerProfileParam(session);
if (owner) next.set("profile", owner);
else next.delete("profile");
return next;
},
{ replace: false },
Expand Down Expand Up @@ -184,7 +192,7 @@ export function ChatSessionList({
return (
<ListItem
key={s.id}
onClick={() => pick(s.id)}
onClick={() => pick(s)}
aria-current={isActive ? "true" : undefined}
className={cn(
"flex-col items-start gap-0.5 rounded px-2 py-1.5",
Expand Down
18 changes: 16 additions & 2 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,9 +369,16 @@ export const api = {
fetchJSON<SessionInfo>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile),
),
getSessionLatestDescendant: (id: string) =>
getSessionLatestDescendant: (id: string, profile = getManagementProfile()) =>
fetchJSON<SessionLatestDescendantResponse>(
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
appendProfileParam(
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
profile,
),
),
getSessionOwnerProfile: (id: string) =>
fetchJSON<SessionOwnerProfileResponse>(
`/api/sessions/${encodeURIComponent(id)}/owner-profile`,
),
deleteSession: (id: string, profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean }>(
Expand Down Expand Up @@ -1673,6 +1680,9 @@ export interface SessionInfo {
output_tokens: number;
preview: string | null;
parent_session_id?: string | null;
/** Owning profile for rows returned from profile-scoped/all-profile session APIs. */
profile?: string | null;
is_default_profile?: boolean;
}

export interface SessionLatestDescendantResponse {
Expand All @@ -1682,6 +1692,10 @@ export interface SessionLatestDescendantResponse {
changed: boolean;
}

export interface SessionOwnerProfileResponse {
profile: string | null;
}

export interface PaginatedSessions {
sessions: SessionInfo[];
total: number;
Expand Down
Loading