diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 08f5d019..10d370f2 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -6,6 +6,9 @@ import type { Settings } from "../settings/settings-store.js"; import { getUser } from "../auth/auth.js"; import type { ReadinessManager } from "../runtime/readiness-manager.js"; +const BOOTSTRAP_FAILURE_MESSAGE = + "Session startup failed. Check configuration and connectivity, then Resume the session."; + export function sessionRoutes( app: FastifyInstance, d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; readiness: ReadinessManager }, @@ -34,11 +37,10 @@ export function sessionRoutes( await Promise.all([configure, retrieval]); info(id, "Starting model"); start(); - } catch (error) { + } catch { d.mgr.teardown(id); - const text = error instanceof Error ? error.message : String(error); void d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined); - rt.bridge.emitClientEvent({ type: "info", level: "error", text: `Session bootstrap failed: ${text}` }); + rt.bridge.emitClientEvent({ type: "info", level: "error", text: BOOTSTRAP_FAILURE_MESSAGE }); rt.bridge.emitClientEvent({ type: "system_event", event: "session_failed" }); rt.bridge.emitClientEvent({ type: "system_event", event: "agent_end" }); } diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 48cd03a9..9935ce5b 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -471,3 +471,51 @@ test("POST /sessions returns after bridge attachment but starts only after retri await new Promise((resolve) => setImmediate(resolve)); expect(started).toBe(true); }); + +test("POST /sessions bootstrap failure emits only a fixed recovery message", async () => { + const published: Array<{ event: string; data: any }> = []; + let listener: ((event: any) => void) | undefined; + const bridge = { + onClientEvent: (callback: (event: any) => void) => { listener = callback; }, + emitClientEvent: (event: any) => listener?.(event), + }; + const runtime = { bridge } as any; + const rawFailure = + "connect https://secret.invalid/bootstrap?token=DO_NOT_LEAK using /srv/private/model-key"; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + mgr: { + createFor: () => runtime, + configure: async () => { throw new Error(rawFailure); }, + start: () => {}, + teardown: () => {}, + } as any, + hub: { + publish: (_id: string, event: string, data: any) => published.push({ event, data }), + } as any, + thtRunner: { + ollamaEnsure: async () => ({ ok: true }), + sessionNew: async () => ({ id: "s-bootstrap" }), + searchPack: async () => {}, + failSession: async () => {}, + } as any, + getSettings: () => ({ workspace: "psd" }) as any, + }); + + const response = await app.inject({ + method: "POST", + url: "/sessions", + payload: { question: "q" }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(response.json()).toEqual({ id: "s-bootstrap" }); + expect(published.map(({ data }) => data)).toContainEqual({ + type: "info", + level: "error", + text: "Session startup failed. Check configuration and connectivity, then Resume the session.", + }); + const clientOutput = JSON.stringify(published); + expect(clientOutput).not.toContain("secret.invalid"); + expect(clientOutput).not.toContain("DO_NOT_LEAK"); + expect(clientOutput).not.toContain("/srv/private/model-key"); +}); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 0d3ced94..34a9d6c7 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -68,6 +68,9 @@ test("Archive accordion expands to reveal archived sessions", async () => { test("Resume from the panel activates the session and closes the panel", async () => { let resumed: string | null = null; + useSessionStore.setState({ + activityLog: [{ kind: "status", phase: "F7", text: "Stale prior activity", level: "info" }], + }); server.use( http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => { resumed = params.id as string; @@ -78,11 +81,9 @@ test("Resume from the panel activates the session and closes the panel", async ( await userEvent.click(await screen.findByText("Attiva uno")); await screen.findByText("Domanda originale"); // panel open await userEvent.click(screen.getByRole("button", { name: /resume/i })); - expect(useSessionStore.getState().activityLog).toContainEqual({ - kind: "lifecycle", - phase: null, - text: "Resuming session", - }); + expect(useSessionStore.getState().activityLog).toEqual([ + { kind: "lifecycle", phase: null, text: "Resuming session" }, + ]); await waitFor(() => expect(resumed).toBe("s1")); await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument()); // panel closed }); @@ -230,7 +231,7 @@ test("closing and reopening Model activity preserves the complete activity log", }); store.applyEvent({ type: "text_delta", text: "Cohort ready" }); }); - const beforeClose = useSessionStore.getState().activityLog; + const beforeClose = useSessionStore.getState().activityLog.map((entry) => ({ ...entry })); expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /model activity/i })); diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index 79f16063..c50e1c08 100644 --- a/frontend/src/shell/ModelActivityPanel.test.tsx +++ b/frontend/src/shell/ModelActivityPanel.test.tsx @@ -109,11 +109,12 @@ test("close calls onClose without mutating the activity log", async () => { ]; useSessionStore.setState({ activityLog }); render(); + const expectedLog = useSessionStore.getState().activityLog.map((entry) => ({ ...entry })); await userEvent.click(screen.getByRole("button", { name: /close model activity/i })); expect(onClose).toHaveBeenCalledOnce(); - expect(useSessionStore.getState().activityLog).toEqual(activityLog); + expect(useSessionStore.getState().activityLog).toEqual(expectedLog); }); test("isNearBottom includes the 48px boundary", () => {