diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e45d47848fb..4e1582fdeb9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -8675,14 +8675,12 @@ async def cancel_oauth_session( -def _session_latest_descendant(session_id: str): +def _session_latest_descendant(session_id: str, db): """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. """ - from hermes_state import SessionDB - def row_get(row, key, index): if isinstance(row, dict): return row.get(key) @@ -8694,62 +8692,58 @@ def _session_latest_descendant(session_id: str): except Exception: return None - db = SessionDB() - try: - sid = db.resolve_session_id(session_id) - if not sid or not db.get_session(sid): - return None, [] + sid = db.resolve_session_id(session_id) + if not sid or not db.get_session(sid): + return None, [] - conn = ( - getattr(db, "conn", None) - or getattr(db, "_conn", None) - or getattr(db, "connection", None) - or getattr(db, "_connection", None) - ) + conn = ( + getattr(db, "conn", None) + or getattr(db, "_conn", None) + or getattr(db, "connection", None) + or getattr(db, "_connection", None) + ) - rows = [] - if conn is not None: - raw_rows = conn.execute( - "SELECT id, parent_session_id, started_at FROM sessions" - ).fetchall() - for row in raw_rows: - rows.append({ - "id": row_get(row, "id", 0), - "parent_session_id": row_get(row, "parent_session_id", 1), - "started_at": row_get(row, "started_at", 2), - }) - else: - rows = db.list_sessions_rich(limit=10000, offset=0) + rows = [] + if conn is not None: + raw_rows = conn.execute( + "SELECT id, parent_session_id, started_at FROM sessions" + ).fetchall() + for row in raw_rows: + rows.append({ + "id": row_get(row, "id", 0), + "parent_session_id": row_get(row, "parent_session_id", 1), + "started_at": row_get(row, "started_at", 2), + }) + else: + rows = db.list_sessions_rich(limit=10000, offset=0) - children = {} - for row in rows: - rid = row.get("id") - parent = row.get("parent_session_id") - if rid and parent: - children.setdefault(parent, []).append(row) + children = {} + for row in rows: + rid = row.get("id") + parent = row.get("parent_session_id") + if rid and parent: + children.setdefault(parent, []).append(row) - def started(row): - try: - return float(row.get("started_at") or 0) - except Exception: - return 0.0 + def started(row): + try: + return float(row.get("started_at") or 0) + except Exception: + return 0.0 - current = sid - path = [sid] - seen = {sid} + current = sid + path = [sid] + seen = {sid} - while children.get(current): - candidates = [r for r in children[current] if r.get("id") not in seen] - if not candidates: - break - candidates.sort(key=started, reverse=True) - current = candidates[0]["id"] - path.append(current) - seen.add(current) + while children.get(current): + candidates = [r for r in children[current] if r.get("id") not in seen] + if not candidates: + break + candidates.sort(key=started, reverse=True) + current = candidates[0]["id"] + path.append(current) + seen.add(current) - return current, path - finally: - db.close() + return current, path # CRITICAL — every literal-path route below MUST be declared BEFORE the @@ -8923,16 +8917,23 @@ 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) - if not latest: - raise HTTPException(status_code=404, detail="Session not found") - return { - "requested_session_id": path[0] if path else session_id, - "session_id": latest, - "path": path, - "changed": bool(path and latest != path[0]), - } +async def get_session_latest_descendant( + session_id: str, + profile: Optional[str] = None, +): + db = _open_session_db_for_profile(profile) + try: + latest, path = _session_latest_descendant(session_id, db) + if not latest: + raise HTTPException(status_code=404, detail="Session not found") + return { + "requested_session_id": path[0] if path else session_id, + "session_id": latest, + "path": path, + "changed": bool(path and latest != path[0]), + } + finally: + db.close() @app.get("/api/sessions/{session_id}/messages") async def get_session_messages(session_id: str, profile: Optional[str] = None): diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 035dddffe4c..ae732bcaba2 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1049,6 +1049,41 @@ class TestWebServerEndpoints: messages = self.client.get("/api/sessions/worker-only/messages?profile=worker").json() assert [m["content"] for m in messages["messages"]] == ["worker"] + def test_latest_descendant_reads_requested_profile(self): + """Chat resume must resolve compression tips in the chat profile DB.""" + from hermes_state import SessionDB + from hermes_cli import profiles as profiles_mod + + worker_home = profiles_mod.get_profile_dir("worker") + worker_home.mkdir(parents=True) + + default_db = SessionDB() + try: + default_db.create_session(session_id="shared-root", source="cli") + finally: + default_db.close() + + worker_db = SessionDB(db_path=worker_home / "state.db") + try: + worker_db.create_session(session_id="shared-root", source="cli") + worker_db.create_session( + session_id="worker-tip", + source="cli", + parent_session_id="shared-root", + ) + finally: + worker_db.close() + + default_resp = self.client.get("/api/sessions/shared-root/latest-descendant") + assert default_resp.status_code == 200 + assert default_resp.json()["session_id"] == "shared-root" + + worker_resp = self.client.get( + "/api/sessions/shared-root/latest-descendant?profile=worker" + ) + assert worker_resp.status_code == 200 + assert worker_resp.json()["session_id"] == "worker-tip" + def test_analytics_endpoints_read_requested_profile(self): from hermes_state import SessionDB from hermes_cli import profiles as profiles_mod diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 0c3286f528e..47cb5e72b05 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -72,7 +72,7 @@ const STATE_TONE: Record< interface ChatSidebarProps { channel: string; - /** Management profile from the dashboard switcher — scopes session.create. */ + /** Chat profile from the dashboard switcher / URL scope. */ profile?: string; className?: string; onDashboardNewSessionRequest?: () => void; @@ -103,8 +103,9 @@ export function ChatSidebar({ // session boots from. We deliberately don't use the sidecar's `session.info` // model: that's a one-time snapshot of the throwaway sidecar agent taken when // its session is created, and it never updates when the model is changed - // elsewhere, so the badge would go stale. `/api/model/info` is profile-scoped - // by `fetchJSON`, so it reads the same profile this sidebar is scoped to. + // elsewhere, so the badge would go stale. Pass the chat profile explicitly so + // this card stays scoped to the PTY even if the global dashboard switcher + // changes while the chat is open. const [effectiveModel, setEffectiveModel] = useState(""); // Whether the effective model supports reasoning effort — gates the // ReasoningPicker. Read from the same `/api/model/info` capabilities the @@ -125,7 +126,7 @@ export function ChatSidebar({ const refreshEffectiveModel = useCallback(() => { void api - .getModelInfo() + .getModelInfo(profile) .then((r) => { if (r?.model) setEffectiveModel(String(r.model)); setSupportsReasoning(!!r?.capabilities?.supports_reasoning); @@ -135,7 +136,7 @@ export function ChatSidebar({ .catch(() => { // Best-effort: keep the last known label rather than blanking it. }); - }, []); + }, [profile]); // Profile or PTY channel change tears down both WebSockets. Bump `version` // (same path as the manual Reconnect button) so the gateway client is @@ -208,6 +209,7 @@ export function ChatSidebar({ gw.close(); }; // `profile` is read from render; scope changes bump `version` → new `gw`. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [gw]); // Event subscriber WebSocket — receives the rebroadcast of every @@ -344,6 +346,7 @@ export function ChatSidebar({ setModelNotice( @@ -391,17 +394,20 @@ export function ChatSidebar({ // Same path the Models page uses (REST /api/model/set), not the // sidecar config.set RPC, which didn't reliably land in the // config.yaml the agent boots from. Always persisted (alwaysGlobal). - loader={api.getModelOptions} + loader={() => api.getModelOptions(profile)} alwaysGlobal onApply={async ({ provider, model, confirmExpensiveModel }) => { setModelNotice(null); setPendingReloadModel(null); - const result = await api.setModelAssignment({ - confirm_expensive_model: confirmExpensiveModel, - scope: "main", - provider, - model, - }); + const result = await api.setModelAssignment( + { + confirm_expensive_model: confirmExpensiveModel, + scope: "main", + provider, + model, + }, + profile, + ); // confirm_required => the dialog shows the expensive-model prompt // and calls back; don't announce until the user confirms. if (!result.confirm_required) { diff --git a/web/src/components/ReasoningPicker.tsx b/web/src/components/ReasoningPicker.tsx index 77ef2e35bdd..cd45986a766 100644 --- a/web/src/components/ReasoningPicker.tsx +++ b/web/src/components/ReasoningPicker.tsx @@ -15,9 +15,8 @@ * running chat session adopts the change on the next `/new` or page reload; * we surface that hint rather than forcing a reload here. * - * Profile scoping: `/api/config` is profile-scoped by `fetchJSON` via the - * global management profile — the same scope the sidebar's `/api/model/info` - * badge reads from — so this writes the profile the sidebar is showing. + * Profile scoping: the sidebar passes the chat profile explicitly, so this + * reads/writes the same config the chat PTY was launched from. */ import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; @@ -35,6 +34,8 @@ interface ReasoningPickerProps { /** Current model string from config — re-reads the saved effort when it * changes (a different model may have been selected). */ currentModel: string; + /** Profile whose config should be read/written. */ + profile?: string; /** Bumped after the model picker saves, to re-read config in lockstep. */ refreshKey?: number; /** Called after a successful change so the sidebar can show an "apply on @@ -44,6 +45,7 @@ interface ReasoningPickerProps { export function ReasoningPicker({ currentModel, + profile, refreshKey = 0, onChanged, }: ReasoningPickerProps) { @@ -53,11 +55,11 @@ export function ReasoningPicker({ const lastFetchKeyRef = useRef(""); useEffect(() => { - const fetchKey = `${currentModel}:${refreshKey}`; + const fetchKey = `${profile ?? ""}:${currentModel}:${refreshKey}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; void api - .getConfig() + .getConfig(profile) .then((cfg) => { const agent = (cfg?.agent as Record | undefined) ?? {}; setEffort(normalizeEffort(agent.reasoning_effort)); @@ -67,7 +69,7 @@ export function ReasoningPicker({ // Best-effort: keep the last known value rather than blanking it. setLoaded(true); }); - }, [currentModel, refreshKey]); + }, [currentModel, profile, refreshKey]); const onSelect = useCallback( (next: string) => { @@ -79,7 +81,7 @@ export function ReasoningPicker({ // pattern — so we never clobber sibling keys. `saveConfig` PUTs the full // object the agent boots from. void api - .getConfig() + .getConfig(profile) .then((cfg) => { const base = (cfg ?? {}) as Record; const agent = @@ -87,7 +89,7 @@ export function ReasoningPicker({ ? { ...(base.agent as Record) } : {}; agent.reasoning_effort = next; - return api.saveConfig({ ...base, agent }); + return api.saveConfig({ ...base, agent }, profile); }) .then(() => { onChanged?.(next); @@ -97,7 +99,7 @@ export function ReasoningPicker({ }) .finally(() => setSaving(false)); }, - [effort, onChanged], + [effort, onChanged, profile], ); return ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5dc982430a9..8aa443828e4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -369,9 +369,12 @@ export const api = { fetchJSON( appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile), ), - getSessionLatestDescendant: (id: string) => + getSessionLatestDescendant: (id: string, profile = getManagementProfile()) => fetchJSON( - `/api/sessions/${encodeURIComponent(id)}/latest-descendant`, + appendProfileParam( + `/api/sessions/${encodeURIComponent(id)}/latest-descendant`, + profile, + ), ), deleteSession: (id: string, profile = getManagementProfile()) => fetchJSON<{ ok: boolean }>( @@ -471,10 +474,12 @@ export const api = { fetchJSON( appendProfileParam(`/api/analytics/models?days=${days}`, profile), ), - getConfig: () => fetchJSON>("/api/config"), + getConfig: (profile = getManagementProfile()) => + fetchJSON>(appendProfileParam("/api/config", profile)), getDefaults: () => fetchJSON>("/api/config/defaults"), getSchema: () => fetchJSON<{ fields: Record; category_order: string[] }>("/api/config/schema"), - getModelInfo: () => fetchJSON("/api/model/info"), + getModelInfo: (profile = getManagementProfile()) => + fetchJSON(appendProfileParam("/api/model/info", profile)), getModelOptions: ( profileOrOptions?: string | { profile?: string; refresh?: boolean }, ) => { @@ -495,7 +500,10 @@ export const api = { const suffix = qs.toString() ? `?${qs.toString()}` : ""; return fetchJSON(`/api/model/options${suffix}`); }, - getAuxiliaryModels: () => fetchJSON("/api/model/auxiliary"), + getAuxiliaryModels: (profile = getManagementProfile()) => + fetchJSON( + appendProfileParam("/api/model/auxiliary", profile), + ), getMoaModels: () => fetchJSON("/api/model/moa"), saveMoaModels: (body: MoaConfigResponse) => fetchJSON("/api/model/moa", { @@ -503,21 +511,30 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), - setModelAssignment: (body: ModelAssignmentRequest) => - fetchJSON("/api/model/set", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - saveConfig: (config: Record) => - fetchJSON<{ ok: boolean }>("/api/config", { + setModelAssignment: ( + body: ModelAssignmentRequest, + profile = getManagementProfile(), + ) => + fetchJSON( + appendProfileParam("/api/model/set", profile), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + saveConfig: (config: Record, profile = getManagementProfile()) => + fetchJSON<{ ok: boolean }>(appendProfileParam("/api/config", profile), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ config }), }), - getConfigRaw: () => fetchJSON<{ yaml: string; path?: string }>("/api/config/raw"), - saveConfigRaw: (yaml_text: string) => - fetchJSON<{ ok: boolean }>("/api/config/raw", { + getConfigRaw: (profile = getManagementProfile()) => + fetchJSON<{ yaml: string; path?: string }>( + appendProfileParam("/api/config/raw", profile), + ), + saveConfigRaw: (yaml_text: string, profile = getManagementProfile()) => + fetchJSON<{ ok: boolean }>(appendProfileParam("/api/config/raw", profile), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ yaml_text }), diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 9ca904a9604..708b9df88c2 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -292,7 +292,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { let cancelled = false; api - .getSessionLatestDescendant(resumeParam) + .getSessionLatestDescendant(resumeParam, scopedProfile) .then((res) => { if (cancelled || !res.session_id || res.session_id === resumeParam) { return; @@ -309,7 +309,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { return () => { cancelled = true; }; - }, [resumeParam, searchParams, setSearchParams]); + }, [resumeParam, scopedProfile, searchParams, setSearchParams]); useEffect(() => { const mql = window.matchMedia("(max-width: 1023px)");