diff --git a/hub/src/web/routes/overseer.test.ts b/hub/src/web/routes/overseer.test.ts index 6880deb895..cc7262caa5 100644 --- a/hub/src/web/routes/overseer.test.ts +++ b/hub/src/web/routes/overseer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, spyOn } from 'bun:test' import { Hono } from 'hono' import { Store } from '../../store' import { SyncEngine } from '../../sync/syncEngine' @@ -125,6 +125,7 @@ describe('overseer routes', () => { it('POST /overseer/converse persists offline reply when brain is unconfigured', async () => { const prev = process.env.OVERSEER_BRAIN_URL delete process.env.OVERSEER_BRAIN_URL + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}) try { const store = new Store(':memory:') const app = buildApp(store) @@ -137,6 +138,10 @@ describe('overseer routes', () => { const body = await res.json() as { brainOnline: boolean; reply: string } expect(body.brainOnline).toBe(false) expect(body.reply).toContain('not configured') + expect(warnSpy).toHaveBeenCalledWith( + '[Overseer][Converse] brain unavailable', + expect.objectContaining({ reason: 'not_configured', kind: 'not_configured', reachable: false }) + ) const recent = await app.request('/api/overseer/converse/recent?limit=5') const recentBody = await recent.json() as { turns: Array<{ operatorText: string; overseerText: string }> } @@ -144,6 +149,7 @@ describe('overseer routes', () => { expect(recentBody.turns[0]?.operatorText).toBe('hello fleet') expect(recentBody.turns[0]?.overseerText).toContain('not configured') } finally { + warnSpy.mockRestore() if (prev === undefined) delete process.env.OVERSEER_BRAIN_URL else process.env.OVERSEER_BRAIN_URL = prev } diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 55749753f4..e75a8311e0 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -205,7 +205,7 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho // Converse — the modality-agnostic conversation core. Runs the brain LLM // with the read-only tools and returns a human-facing reply + tool trace. // Text is the first transport (debug settings); voice/XR reuse this. When - // the brain is offline (GPU pulled for VR), returns brainOnline:false with a + // the brain is offline, returns brainOnline:false with a // friendly message rather than an error. // // Continuity: hub assembles prior `convo_turn`s (budgeted) + latest operator @@ -244,6 +244,14 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho model: parsed.data.model })) if (!config) { + // Soft 200 for clients — journal still needs a greppable line (access log is 200). + console.warn('[Overseer][Converse] brain unavailable', { + reason: 'not_configured', + kind: 'not_configured', + reachable: false, + model: null, + profile: parsed.data.profile ?? null + }) const reply = 'The Overseer brain is not configured on this hub (set OVERSEER_BRAIN_URL). I can still show raw events and inbox items, but I cannot answer in conversation yet.' persistOverseerConvoExchange(overseer, assembled, { operatorText: lastOperator, @@ -289,9 +297,19 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho if (error instanceof BrainUnavailableError) { // Reachable-but-failed (http 4xx/5xx, malformed body) is a converse // bug, not an offline brain — do not mislabel it as GPU/VR downtime. + // Soft 200 for clients — structured warn so journalctl can find it. + console.warn('[Overseer][Converse] brain unavailable', { + reason: error.reachable ? 'request_error' : 'unreachable', + kind: error.kind, + reachable: error.reachable, + status: error.status ?? null, + model: config.model, + profile: parsed.data.profile ?? null, + message: error.message.slice(0, 200) + }) const reply = error.reachable ? 'I reached the Overseer brain but could not complete the tool conversation (request error). This is a converse-loop issue, not the brain being offline — please retry, and flag it if it persists.' - : 'The Overseer brain is offline right now (the GPU may be in use for VR). Try again shortly — your events and inbox are still being captured.' + : 'The Overseer brain is offline right now. Try again shortly — your events and inbox are still being captured.' persistOverseerConvoExchange(overseer, assembled, { operatorText: lastOperator, overseerText: reply, diff --git a/web/src/components/settings/OverseerChatDebugControls.tsx b/web/src/components/settings/OverseerChatDebugControls.tsx index 45be2a8a47..86813d9eb7 100644 --- a/web/src/components/settings/OverseerChatDebugControls.tsx +++ b/web/src/components/settings/OverseerChatDebugControls.tsx @@ -40,7 +40,15 @@ export function OverseerChatDebugControls() { useEffect(() => { if (!open || !api || profiles.length > 0) return void api.fetchOverseerBrains() - .then((res) => setProfiles(res.profiles)) + .then((res) => { + setProfiles(res.profiles) + // Prefer hub active brain — do not stick on hard-coded "default" when + // the operator saved a working profile (e.g. local loopback). + if (res.active?.profile) { + setSelectedProfile(res.active.profile) + setSelectedModel(res.active.model ?? '') + } + }) .catch(() => { /* brains list is optional chrome */ }) }, [open, api, profiles.length])