Skip to content
Merged
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
135 changes: 71 additions & 64 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7998,14 +7998,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)
Expand All @@ -8017,62 +8015,58 @@ def row_get(row, key, index):
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, []

conn = (
getattr(db, "conn", None)
or getattr(db, "_conn", None)
or getattr(db, "connection", None)
or getattr(db, "_connection", None)
)
sid = db.resolve_session_id(session_id)
if not sid or not db.get_session(sid):
return 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)
conn = (
getattr(db, "conn", None)
or getattr(db, "_conn", None)
or getattr(db, "connection", None)
or getattr(db, "_connection", None)
)

children = {}
for row in rows:
rid = row.get("id")
parent = row.get("parent_session_id")
if rid and parent:
children.setdefault(parent, []).append(row)
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)

def started(row):
try:
return float(row.get("started_at") or 0)
except Exception:
return 0.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)

current = sid
path = [sid]
seen = {sid}
def started(row):
try:
return float(row.get("started_at") or 0)
except Exception:
return 0.0

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)
current = sid
path = [sid]
seen = {sid}

return current, path
finally:
db.close()
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


# CRITICAL — every literal-path route below MUST be declared BEFORE the
Expand Down Expand Up @@ -8246,16 +8240,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):
Expand Down Expand Up @@ -13093,7 +13094,13 @@ def _resolve_chat_argv(
env["HERMES_HOME"] = str(profile_dir)

if resume:
latest_resume, _latest_path = _session_latest_descendant(resume)
_resume_db = _open_session_db_for_profile(
requested if profile_dir is not None else None
)
try:
latest_resume, _latest_path = _session_latest_descendant(resume, _resume_db)
finally:
_resume_db.close()
if latest_resume:
resume = latest_resume
env["HERMES_TUI_RESUME"] = resume
Expand Down
35 changes: 35 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,41 @@ def test_sessions_endpoint_reads_requested_profile(self):
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
Expand Down
30 changes: 18 additions & 12 deletions web/src/components/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -344,6 +346,7 @@ export function ChatSidebar({
<Card className="py-0">
<ReasoningPicker
currentModel={modelName}
profile={profile}
refreshKey={modelRefreshKey}
onChanged={(effort) =>
setModelNotice(
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 11 additions & 9 deletions web/src/components/ReasoningPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand All @@ -44,6 +45,7 @@ interface ReasoningPickerProps {

export function ReasoningPicker({
currentModel,
profile,
refreshKey = 0,
onChanged,
}: ReasoningPickerProps) {
Expand All @@ -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<string, unknown> | undefined) ?? {};
setEffort(normalizeEffort(agent.reasoning_effort));
Expand All @@ -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) => {
Expand All @@ -79,15 +81,15 @@ 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<string, unknown>;
const agent =
base.agent && typeof base.agent === "object"
? { ...(base.agent as Record<string, unknown>) }
: {};
agent.reasoning_effort = next;
return api.saveConfig({ ...base, agent });
return api.saveConfig({ ...base, agent }, profile);
})
.then(() => {
onChanged?.(next);
Expand All @@ -97,7 +99,7 @@ export function ReasoningPicker({
})
.finally(() => setSaving(false));
},
[effort, onChanged],
[effort, onChanged, profile],
);

return (
Expand Down
Loading
Loading