Fix dashboard chat model profile scoping

This commit is contained in:
Shannon Sands 2026-06-22 13:26:47 +10:00 committed by Teknium
parent 75de0057bc
commit 543f069093
6 changed files with 162 additions and 101 deletions

View file

@ -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):

View file

@ -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

View file

@ -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({
<Card className="py-0">
<ReasoningPicker
currentModel={modelName}
profile={profile}
refreshKey={modelRefreshKey}
onChanged={(effort) =>
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) {

View file

@ -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<string, unknown> | 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<string, unknown>;
const agent =
@ -87,7 +89,7 @@ export function ReasoningPicker({
? { ...(base.agent as Record<string, unknown>) }
: {};
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 (

View file

@ -369,9 +369,12 @@ 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,
),
),
deleteSession: (id: string, profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean }>(
@ -471,10 +474,12 @@ export const api = {
fetchJSON<ModelsAnalyticsResponse>(
appendProfileParam(`/api/analytics/models?days=${days}`, profile),
),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getConfig: (profile = getManagementProfile()) =>
fetchJSON<Record<string, unknown>>(appendProfileParam("/api/config", profile)),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
getModelInfo: (profile = getManagementProfile()) =>
fetchJSON<ModelInfoResponse>(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<ModelOptionsResponse>(`/api/model/options${suffix}`);
},
getAuxiliaryModels: () => fetchJSON<AuxiliaryModelsResponse>("/api/model/auxiliary"),
getAuxiliaryModels: (profile = getManagementProfile()) =>
fetchJSON<AuxiliaryModelsResponse>(
appendProfileParam("/api/model/auxiliary", profile),
),
getMoaModels: () => fetchJSON<MoaConfigResponse>("/api/model/moa"),
saveMoaModels: (body: MoaConfigResponse) =>
fetchJSON<MoaConfigResponse & { ok: boolean }>("/api/model/moa", {
@ -503,21 +511,30 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
setModelAssignment: (body: ModelAssignmentRequest) =>
fetchJSON<ModelAssignmentResponse>("/api/model/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
saveConfig: (config: Record<string, unknown>) =>
fetchJSON<{ ok: boolean }>("/api/config", {
setModelAssignment: (
body: ModelAssignmentRequest,
profile = getManagementProfile(),
) =>
fetchJSON<ModelAssignmentResponse>(
appendProfileParam("/api/model/set", profile),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
),
saveConfig: (config: Record<string, unknown>, 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 }),

View file

@ -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)");