diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index bba4d150..91822b94 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # ThothII — Project State -> Starting-point snapshot for new sessions. Last updated: 2026-07-12 (Docker deploy live + codex portable-deployment merged). +> Starting-point snapshot for new sessions. Last updated: 2026-07-13 (direct adapters + Pi project trust fixed live). > Point a fresh session here ("read PROJECT_STATE.md") before substantial work. ## Deployment — Docker locale (Profile A, co-located) — LIVE 2026-07-12 @@ -16,6 +16,25 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal - **Standalone/dev**: `docker-compose.dev.yml` (rete propria, porte host 8787/8090) + `scripts/docker-smoke.sh`. - Piano dettagliato: `docs/superpowers/plans/2026-07-12-local-docker-deploy-implementation.md`. +### Runtime incident fixes — LIVE 2026-07-13 + +- The bind-mounted Pi profile came from host paths and did not trust `/app/harness`. + Pi 0.80 consequently loaded **zero** project extensions, prompts and skills, silently + sending `/nuova-domanda`/`/riprendi-sessione` to the model as plain text. The core + entrypoint now idempotently adds only `/app/harness` to the persistent + `/home/thoth/.pi/agent/trust.json`, preserving all existing decisions. +- The gate embeds the canonical `tht-sessione/SKILL.md` in the one-shot kickoff system + prompt and explicitly prohibits repository discovery. A live RPC `get_commands` must + show `torna`, `nuova-domanda`, `riprendi-sessione`, and `skill:tht-sessione` after deploy. +- Workspace identity is derived from the resolved config path, so + `config/tht.yaml -> workspaces/local.yaml` matches DWH artifact ownership (`local`). +- Direct pgvector now discovers the actual namespaces of the `vector` type and cosine + operator from PostgreSQL catalogs. This supports server layout `vectors.*` tables with + the extension installed in `public`. +- Live verification: session `2026-07-13-074712-dammi-la-lista-dei-pazienti-che-haoo-fat` + resumed directly at F1, ran `tht session show`, and completed `tht search pack` + (12 tables, 0 evidence, 2 solved) without repository exploration or adapter errors. + ## What ThothII is A **human-in-the-loop datamart builder**: it turns a natural-language question into diff --git a/backend/src/app.ts b/backend/src/app.ts index 23a26e39..b3b965ea 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,6 +11,7 @@ import { metaRoutes, type ListModelsFn } from "./routes/meta.js"; import { settingsRoutes, effectiveSettings } from "./routes/settings.js"; import { createPiModelLister } from "./pi/list-models.js"; import { loadSettings, type Settings } from "./settings/settings-store.js"; +import { ReadinessManager } from "./runtime/readiness-manager.js"; export interface BuildAppDeps { thtRunner?: ThtRunner; @@ -18,6 +19,7 @@ export interface BuildAppDeps { spawnFn?: () => any; listModels?: ListModelsFn; getSettings?: () => Settings; + readiness?: ReadinessManager; } export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance { @@ -38,6 +40,10 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc }); const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined); const hub = new SseHub(); + const readiness = deps?.readiness ?? new ReadinessManager( + tht as ThtRunner, + Math.round(config.ollamaEnsureTimeoutMs / 1000), + ); const listModels = deps?.listModels ?? createPiModelLister(config); const getSettings = deps?.getSettings ?? (() => effectiveSettings(config, loadSettings(config))); @@ -50,8 +56,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc }); app.get("/health", async () => ({ status: "ok" })); sessionRoutes(app, { - mgr, tht: tht as ThtRunner, hub, getSettings, - ollamaEnsureTimeoutSec: Math.round(config.ollamaEnsureTimeoutMs / 1000), + mgr, tht: tht as ThtRunner, hub, getSettings, readiness, }); sqlRoutes(app, { tht: tht as ThtRunner }); metaRoutes(app, { harnessDir: config.harnessDir, listModels }); diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index 2f95c613..bfecedb1 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -12,6 +12,15 @@ export interface SessionRuntime { child: ChildProcessWithoutNullStreams; } +export interface RuntimeOptions { + provider?: string; + model?: string; + thinking?: string; + author?: string; + question?: string; + mode?: "new" | "resume"; +} + /** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */ type SpawnFn = ( command: string, @@ -68,17 +77,13 @@ export class PiProcessManager { get(id: string): SessionRuntime | undefined { return this.runtimes.get(id); } - async spawnFor( - sessionId: string, - o: { provider?: string; model?: string; thinking?: string; author?: string; question?: string; mode?: "new" | "resume" }, - ): Promise { - // Idempotent per session id: tear down any existing runtime for this id - // first (before the cap check) so a resume/respawn neither leaks the old - // child nor falsely hits the process cap. + /** Spawn and register a runtime synchronously, without starting a model turn. */ + createFor(sessionId: string, o: RuntimeOptions = {}): SessionRuntime { + // A duplicate start must never tear down a live session: that used to send + // SIGTERM to the in-flight Pi process and lose its pending gate. const existing = this.runtimes.get(sessionId); if (existing) { - existing.child.kill(); - this.runtimes.delete(sessionId); + throw new Error(`session runtime already active: ${sessionId}`); } if (this.runtimes.size >= this.cfg.maxPiProcesses) { throw new Error("max Pi processes reached"); @@ -103,24 +108,41 @@ export class PiProcessManager { level: "error", text: `Pi process exited unexpectedly (code ${code ?? "?"})`, }); + rt.bridge.emitClientEvent({ type: "system_event", event: "session_failed" }); rt.bridge.emitClientEvent({ type: "system_event", event: "agent_end" }); } }); + return rt; + } + + /** Configure model and thinking. Safe to run alongside deterministic retrieval. */ + async configure(rt: SessionRuntime, o: RuntimeOptions = {}): Promise { + const provider = canonicalPiProvider(o.provider ?? this.cfg.defaults.provider); const model = o.model ?? this.cfg.defaults.model; const thinking = o.thinking ?? this.cfg.defaults.thinking; if (provider && model) { - await rpc.request({ type: "set_model", provider, modelId: model } as object & { type: string }); + await rt.rpc.request({ type: "set_model", provider, modelId: model } as object & { type: string }); } if (thinking) { - await rpc.request({ type: "set_thinking_level", level: thinking } as object & { type: string }); + await rt.rpc.request({ type: "set_thinking_level", level: thinking } as object & { type: string }); } + } + /** Start the first turn only after callers have attached the runtime bridge. */ + start(sessionId: string, rt: SessionRuntime, o: RuntimeOptions = {}): void { + if (this.runtimes.get(sessionId) !== rt) throw new Error("session runtime is no longer active"); const message = o.mode === "resume" ? `/riprendi-sessione ${sessionId}` : `/nuova-domanda ${JSON.stringify(o.question ?? "")}`; - rpc.send({ type: "prompt", message }); + rt.rpc.send({ type: "prompt", message }); + } + + async spawnFor(sessionId: string, o: RuntimeOptions = {}): Promise { + const rt = this.createFor(sessionId, o); + await this.configure(rt, o); + this.start(sessionId, rt, o); return rt; } diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 6ff89ce8..0a6fe55c 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -4,15 +4,57 @@ import type { ThtRunner } from "../tht/tht-runner.js"; import type { SseHub } from "../sse/sse-hub.js"; import type { Settings } from "../settings/settings-store.js"; import { getUser } from "../auth/auth.js"; +import type { ReadinessManager } from "../runtime/readiness-manager.js"; export function sessionRoutes( app: FastifyInstance, - d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; ollamaEnsureTimeoutSec: number }, + d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; readiness: ReadinessManager }, ) { + const info = (id: string, text: string, level = "info") => + d.hub.publish(id, "info", { type: "info", level, text }); + + const bindRuntime = (id: string, rt: ReturnType) => + rt.bridge.onClientEvent((e) => { + if (e.type === "system_event" && e.event === "session_failed") { + void d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined); + } + d.hub.publish(id, e.type, e); + }); + + const bootstrap = ( + id: string, + rt: ReturnType, + configure: Promise, + retrieval: Promise | null, + start: () => void, + ) => { + void (async () => { + try { + if (retrieval) info(id, "Preparing retrieval context"); + await Promise.all([configure, retrieval]); + info(id, "Starting model"); + start(); + } catch (error) { + 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: "system_event", event: "session_failed" }); + rt.bridge.emitClientEvent({ type: "system_event", event: "agent_end" }); + } + })(); + }; + + app.post("/runtime/prewarm", async (_req, reply) => { + const workspace = d.getSettings().workspace ?? ""; + void d.readiness.ensure(workspace).catch(() => undefined); + return reply.code(202).send({ status: "warming" }); + }); + app.post("/sessions", async (req, reply) => { const b = req.body as { question: string; name?: string }; const s = d.getSettings(); - const ensure = await d.tht.ollamaEnsure(s.workspace ?? "", d.ollamaEnsureTimeoutSec); + const ensure = await d.readiness.ensure(s.workspace ?? ""); if (!ensure.ok) return reply.code(503).send({ error: ensure.error ?? "Ollama/embeddings non disponibili" }); // Settings (global) supply workspace/provider/model/thinking. The new-question // form sends only the question text. `workspace` selects the tht `-c `. @@ -24,14 +66,22 @@ export function sessionRoutes( model: s.model, thinking: s.thinking, }); - const rt = await d.mgr.spawnFor(id, { + const options = { provider: s.provider, model: s.model, - thinking: s.thinking, + // Keep the saved preference in the manifest; F1 starts tool-first. + thinking: "off", author: getUser(req).id, question: b.question, - }); - rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e)); + }; + const rt = d.mgr.createFor(id, options); + bindRuntime(id, rt); + info(id, "Session created"); + bootstrap( + id, rt, d.mgr.configure(rt, options), + d.tht.searchPack(b.question, id, s.workspace), + () => d.mgr.start(id, rt, options), + ); return { id }; }); app.get("/sessions", async () => d.tht.sessionList(d.getSettings().workspace)); @@ -51,20 +101,43 @@ export function sessionRoutes( }); app.post("/sessions/:id/resume", async (req, reply) => { const id = (req.params as any).id; + if (d.mgr.get(id)) { + // Idempotent resume: reattach the browser to the existing Pi runtime. + // Do not respawn it (which would discard a pending reviewer widget). + return reply.code(200).send({ id, alreadyActive: true }); + } const manifest = (await d.tht.sessionShow(id, d.getSettings().workspace)) as { status?: string; archived?: boolean } | null; if (manifest?.status === "finalized" || manifest?.archived) { return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" }); } - const ensure = await d.tht.ollamaEnsure(d.getSettings().workspace ?? "", d.ollamaEnsureTimeoutSec); + const settings = d.getSettings(); + const ensure = await d.readiness.ensure(settings.workspace ?? ""); if (!ensure.ok) return reply.code(503).send({ error: ensure.error ?? "Ollama/embeddings non disponibili" }); - const rt = await d.mgr.resume(id, d.tht); - rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e)); + const saved = manifest as { provider?: string; model?: string; thinking?: string } | null; + const options = { + provider: saved?.provider, + model: saved?.model, + // Phase 1 must reach a widget instead of exposing a long reasoning trace. + // The session keeps its saved preference for later turns. + thinking: "off", + author: getUser(req).id, + mode: "resume" as const, + }; + await d.tht.reopenSession(id, settings.workspace); + const rt = d.mgr.createFor(id, options); + bindRuntime(id, rt); + info(id, "Resuming session"); + bootstrap(id, rt, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options)); return reply.code(200).send({ id }); }); app.post("/sessions/:id/close", async (req) => { const id = (req.params as { id: string }).id; - d.mgr.teardown(id); - d.hub.clear(id); + try { + await d.tht.closeSession(id, d.getSettings().workspace); + } finally { + d.mgr.teardown(id); + d.hub.clear(id); + } return { closed: true }; }); app.get("/sessions/:id/events", (req, reply) => { @@ -76,6 +149,7 @@ export function sessionRoutes( reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", Connection: "keep-alive", "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", diff --git a/backend/src/runtime/readiness-manager.ts b/backend/src/runtime/readiness-manager.ts new file mode 100644 index 00000000..9cc17b63 --- /dev/null +++ b/backend/src/runtime/readiness-manager.ts @@ -0,0 +1,44 @@ +import type { OllamaEnsureResult, ThtRunner } from "../tht/tht-runner.js"; + +interface ReadyEntry { + expiresAt: number; + result: OllamaEnsureResult; +} + +/** + * Deduplicates embedding readiness checks and keeps only short-lived successes. + * Failures are deliberately not cached so a submit can retry after a transient outage. + */ +export class ReadinessManager { + private inFlight = new Map>(); + private ready = new Map(); + + constructor( + private tht: ThtRunner, + private timeoutSec: number, + private ttlMs = 60_000, + private now: () => number = Date.now, + ) {} + + ensure(workspace = ""): Promise { + const cached = this.ready.get(workspace); + if (cached && cached.expiresAt > this.now()) return Promise.resolve(cached.result); + if (cached) this.ready.delete(workspace); + + const current = this.inFlight.get(workspace); + if (current) return current; + + const pending = this.tht.ollamaEnsure(workspace, this.timeoutSec) + .then((result) => { + if (result.ok) { + this.ready.set(workspace, { result, expiresAt: this.now() + this.ttlMs }); + } + return result; + }) + .finally(() => { + if (this.inFlight.get(workspace) === pending) this.inFlight.delete(workspace); + }); + this.inFlight.set(workspace, pending); + return pending; + } +} diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index 7bf1dd95..2b81ee73 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -83,8 +83,8 @@ export class ThtRunner { return JSON.parse(stdout) as T; } - private async ok(args: string[]): Promise { - const { code, stderr } = await this.run(args); + private async ok(args: string[], workspace?: string): Promise { + const { code, stderr } = await this.run(args, workspace); if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`); } @@ -109,6 +109,13 @@ export class ThtRunner { return this.json<{ id: string }>(a, o.workspace); } + /** Build and persist the deterministic F1 retrieval pack for a new session. */ + async searchPack(question: string, sessionId: string, workspace?: string): Promise { + const args = ["search", "pack", question, "--session", sessionId]; + const { code, stderr } = await this.run(args, workspace); + if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`); + } + sessionList(workspace?: string) { return this.json(["session", "list", "--json"], workspace); } @@ -137,6 +144,9 @@ export class ThtRunner { return { path: stdout.trim() }; } + closeSession(id: string, workspace?: string) { return this.ok(["session", "close", id], workspace); } + failSession(id: string, workspace?: string) { return this.ok(["session", "fail", id], workspace); } + reopenSession(id: string, workspace?: string) { return this.ok(["session", "reopen", id], workspace); } setName(id: string, name: string) { return this.ok(["session", "set-name", id, "--name", name]); } setGroup(id: string, group: string) { return this.ok(["session", "set-group", id, "--group", group]); } archive(id: string) { return this.ok(["session", "archive", id]); } diff --git a/backend/test/e2e-f1.test.ts b/backend/test/e2e-f1.test.ts index cea7c778..daa5f355 100644 --- a/backend/test/e2e-f1.test.ts +++ b/backend/test/e2e-f1.test.ts @@ -32,6 +32,7 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { ollamaEnsure: async () => ({ ok: true }), + searchPack: async () => {}, sessionNew: async () => ({ id: "s1" }), sessionShow: async (_id: string) => ({ id: "s1", provider: undefined, model: undefined, thinking: undefined }), sessionList: async () => [], diff --git a/backend/test/health.test.ts b/backend/test/health.test.ts index b02fa401..42add98f 100644 --- a/backend/test/health.test.ts +++ b/backend/test/health.test.ts @@ -38,6 +38,7 @@ test("SSE response headers are flushed before the first event", async () => { ]); expect(response.headers.get("content-type")).toContain("text/event-stream"); expect(response.headers.get("cache-control")).toBe("no-cache"); + expect(response.headers.get("x-accel-buffering")).toBe("no"); } finally { controller.abort(); await app.close(); diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index 413e2ee4..fc220242 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -36,27 +36,30 @@ test("l'exit del child rimuove il runtime dalla mappa (exit handler)", async () expect(mgr.get("exit-test")).toBeUndefined(); }); -test("spawnFor sullo STESSO id uccide il vecchio child e sostituisce il runtime (count resta 1)", async () => { +test("spawnFor sullo STESSO id rifiuta il duplicato senza interrompere il runtime attivo", async () => { const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" }); const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any }); const first = await mgr.spawnFor("dup-id", {}); expect(mgr.count()).toBe(1); - const firstExited = new Promise((res) => first.child.on("exit", () => res())); - const second = await mgr.spawnFor("dup-id", {}); - await firstExited; // the old child was killed by the idempotent respawn + await expect(mgr.spawnFor("dup-id", {})).rejects.toThrow( + "session runtime already active: dup-id", + ); expect(mgr.count()).toBe(1); - expect(mgr.get("dup-id")).toBe(second); - expect(second).not.toBe(first); + expect(mgr.get("dup-id")).toBe(first); mgr.teardown("dup-id"); }); test("l'exit del VECCHIO child non elimina il nuovo runtime (exit identity-checked)", async () => { const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" }); - const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any }); - const first = await mgr.spawnFor("respawn-id", {}); - const second = await mgr.spawnFor("respawn-id", {}); + const firstChild = recordingChild(); + const secondChild = recordingChild(); + const children = [firstChild, secondChild]; + const mgr = new PiProcessManager(cfg, { spawnFn: () => children.shift() as any }); + const first = mgr.createFor("respawn-id", {}); + mgr.teardown("respawn-id"); + const second = mgr.createFor("respawn-id", {}); // The old child's exit handler fires after the respawn; it must NOT evict `second`. - await new Promise((res) => setImmediate(res)); + firstChild.emit("exit", 0); expect(mgr.get("respawn-id")).toBe(second); expect(mgr.count()).toBe(1); void first; @@ -91,6 +94,7 @@ test("un exit INATTESO del child notifica il client (info error + agent_end)", a child.emit("exit", 137); expect(seen).toEqual([ { type: "info", level: "error", text: expect.stringContaining("137") }, + { type: "system_event", event: "session_failed" }, { type: "system_event", event: "agent_end" }, ]); expect(mgr.count()).toBe(0); @@ -138,6 +142,19 @@ test("spawnFor new mode forwards the real question instead of kickoff", async () mgr.teardown("sid-question"); }); +test("createFor does not prompt until start is called", async () => { + const cfg = loadConfig({}); + const child = recordingChild(); + const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any }); + const rt = mgr.createFor("sid-deferred", { question: "q" }); + expect(child._writes).toEqual([]); + await mgr.configure(rt, {}); + expect(child._writes).toEqual([]); + mgr.start("sid-deferred", rt, { question: "q" }); + expect(JSON.parse(child._writes.at(-1)!).message).toBe('/nuova-domanda "q"'); + mgr.teardown("sid-deferred"); +}); + test("production spawn uses explicit Pi path and passes portable data root without rewriting PATH", async () => { vi.stubEnv("PATH", "/usr/local/bin:/usr/bin"); vi.stubEnv("PI_PROVIDER_API_KEY", "provider-secret"); diff --git a/backend/test/readiness-manager.test.ts b/backend/test/readiness-manager.test.ts new file mode 100644 index 00000000..be0cb039 --- /dev/null +++ b/backend/test/readiness-manager.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "vitest"; +import { ReadinessManager } from "../src/runtime/readiness-manager.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +test("readiness is single-flight per workspace and caches a short-lived success", async () => { + let now = 1_000; + let calls = 0; + const first = deferred(); + const tht = { + ollamaEnsure: async () => { + calls += 1; + return first.promise; + }, + } as any; + const readiness = new ReadinessManager(tht, 60, 500, () => now); + + const a = readiness.ensure("psd"); + const b = readiness.ensure("psd"); + expect(calls).toBe(1); + first.resolve({ ok: true, model_name: "embed" }); + await expect(a).resolves.toMatchObject({ ok: true }); + await expect(b).resolves.toMatchObject({ ok: true }); + + await readiness.ensure("psd"); + expect(calls).toBe(1); + now += 501; + tht.ollamaEnsure = async () => { calls += 1; return { ok: true }; }; + await readiness.ensure("psd"); + expect(calls).toBe(2); +}); + +test("readiness keys workspaces independently", async () => { + const seen: string[] = []; + const tht = { + ollamaEnsure: async (workspace: string) => { + seen.push(workspace); + return { ok: true }; + }, + } as any; + const readiness = new ReadinessManager(tht, 60); + await Promise.all([readiness.ensure("a"), readiness.ensure("b")]); + expect(seen.sort()).toEqual(["a", "b"]); +}); + +test("readiness does not cache failed results", async () => { + let calls = 0; + const tht = { + ollamaEnsure: async () => { + calls += 1; + return calls === 1 ? { ok: false, error: "down" } : { ok: true }; + }, + } as any; + const readiness = new ReadinessManager(tht, 60); + await expect(readiness.ensure("psd")).resolves.toMatchObject({ ok: false }); + await expect(readiness.ensure("psd")).resolves.toMatchObject({ ok: true }); + expect(calls).toBe(2); +}); diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index e0122449..b1a89e2c 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -28,6 +28,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a }), { thtRunner: { ollamaEnsure: async () => ({ ok: true }), + searchPack: async () => {}, sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; }, sessionList: async () => [{ id: "s1" }], } as any, @@ -50,6 +51,7 @@ test("POST /sessions/:id/response inoltra al bridge (no error)", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { ollamaEnsure: async () => ({ ok: true }), + searchPack: async () => {}, sessionNew: async () => ({ id: "s1" }), sessionList: async () => [], } as any, @@ -134,6 +136,7 @@ test("POST /sessions refuses with 503 when ollamaEnsure fails (no session create const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { ollamaEnsure: async () => ({ ok: false, stage: "model", error: "modello non installato" }), + searchPack: async () => {}, sessionNew: async () => { createdCalled = true; return { id: "s1" }; }, } as any, getSettings: () => ({ workspace: "psd" }) as any, @@ -150,6 +153,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { ollamaEnsure: async (ws: string) => { ensureWs = ws; return { ok: true }; }, + searchPack: async () => {}, sessionNew: async () => ({ id: "s1" }), } as any, getSettings: () => ({ workspace: "psd" }) as any, @@ -183,3 +187,59 @@ test("POST /sessions/:id/resume refuses with 503 when ollamaEnsure fails", async const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" }); expect(res.statusCode).toBe(503); }); + +test("POST /runtime/prewarm returns 202 without awaiting readiness", async () => { + let workspace: string | undefined; + let finish!: (value: any) => void; + const pending = new Promise((resolve) => { finish = resolve; }); + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: {} as any, + readiness: { + ensure: (ws: string) => { workspace = ws; return pending; }, + } as any, + getSettings: () => ({ workspace: "psd" }) as any, + }); + const res = await app.inject({ method: "POST", url: "/runtime/prewarm" }); + expect(res.statusCode).toBe(202); + expect(res.json()).toEqual({ status: "warming" }); + expect(workspace).toBe("psd"); + finish({ ok: true }); +}); + +test("POST /sessions returns after bridge attachment but starts only after retrieval", async () => { + let finishPack!: () => void; + const pack = new Promise((resolve) => { finishPack = resolve; }); + let bridgeAttached = false; + let started = false; + const bridge = { + onClientEvent: () => { bridgeAttached = true; }, + emitClientEvent: () => {}, + }; + const runtime = { bridge } as any; + const mgr = { + createFor: () => runtime, + configure: async () => {}, + start: () => { + expect(bridgeAttached).toBe(true); + started = true; + }, + teardown: () => {}, + } as any; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + mgr, + thtRunner: { + ollamaEnsure: async () => ({ ok: true }), + sessionNew: async () => ({ id: "s-early" }), + searchPack: async () => pack, + } as any, + getSettings: () => ({ workspace: "psd" }) as any, + }); + + const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); + expect(res.json()).toEqual({ id: "s-early" }); + expect(bridgeAttached).toBe(true); + expect(started).toBe(false); + finishPack(); + await new Promise((resolve) => setImmediate(resolve)); + expect(started).toBe(true); +}); diff --git a/backend/test/tht-runner.test.ts b/backend/test/tht-runner.test.ts index 753160aa..59f22aa5 100644 --- a/backend/test/tht-runner.test.ts +++ b/backend/test/tht-runner.test.ts @@ -24,6 +24,17 @@ test("sessionNew parses id from JSON", async () => { expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" }); }); +test("searchPack persists retrieval context with session and workspace", async () => { + const calls: any[] = []; + const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" }); + r.run = async (args, workspace) => { + calls.push({ args, workspace }); + return { code: 0, stdout: "", stderr: "" }; + }; + await r.searchPack("domanda", "sid", "psd"); + expect(calls).toEqual([{ args: ["search", "pack", "domanda", "--session", "sid"], workspace: "psd" }]); +}); + test("run passes configured THT_DATA_ROOT and preserves the remaining environment", async () => { const previousDataRoot = process.env.THT_DATA_ROOT; const previousCa = process.env.NODE_EXTRA_CA_CERTS; diff --git a/brain/codebase/datamart-builder-deployment-gotchas.md b/brain/codebase/datamart-builder-deployment-gotchas.md new file mode 100644 index 00000000..14d2a2b1 --- /dev/null +++ b/brain/codebase/datamart-builder-deployment-gotchas.md @@ -0,0 +1,10 @@ +# Datamart Builder deployment gotchas + +- Il percorso pubblico attraversa due reverse proxy: nginx host → nginx Omics Portal → core ThothII. +- Per SSE, ogni livello deve disabilitare `proxy_buffering`, `proxy_request_buffering` e cache, usare HTTP/1.1, timeout lunghi e propagare `X-Accel-Buffering: no`. +- Il core aggiunge `X-Accel-Buffering: no` alla risposta EventSource; Omics Portal lo riaggiunge esplicitamente per i proxy a monte. +- Una sonda utile deve attraversare il portale autenticato e misurare l'arrivo degli header `200 text/event-stream`, non solo interrogare il core nel network Docker. +- La configurazione nginx host attiva vive in `/etc/nginx/sites-available/policlinicosandonato`; validare con `nginx -t` prima del reload. +- Django può tenere in memoria il manifest Vite per worker: dopo un rebuild del frontend riavviare anche i worker Omics Portal, altrimenti richieste diverse possono produrre hash asset vecchi e nuovi. +- Nel profilo server legacy, `vector_db` è una connessione pgvector RW condivisa; il factory può riusarla come writer solo con `profile=server`. La workstation senza writer REST deve restare read-only. +- Non convertire in-place `local.yaml` da chiavi legacy a risorse moderne durante un incident fix: cambia il binding del workspace e può invalidare gli snapshot DWH attivi. diff --git a/brain/index.md b/brain/index.md new file mode 100644 index 00000000..3de28ca5 --- /dev/null +++ b/brain/index.md @@ -0,0 +1,3 @@ +# Brain + +- [[codebase/datamart-builder-deployment-gotchas]] diff --git a/docker/core-entrypoint.sh b/docker/core-entrypoint.sh index 1fc49237..6f9049cc 100755 --- a/docker/core-entrypoint.sh +++ b/docker/core-entrypoint.sh @@ -5,6 +5,11 @@ set -euo pipefail export THT_CONFIG="${THT_CONFIG:-/app/harness/workspaces/local.yaml}" +# Pi 0.80 gates every project-local extension, prompt and skill behind its persistent +# trust store. The mounted profile may come from another host and therefore not contain +# the container path. Preserve its existing decisions and authorize only this harness. +node /app/docker/ensure-pi-trust.mjs "${THT_HARNESS_DIR:-/app/harness}" + cmd="${1:-server}" case "$cmd" in server) diff --git a/docker/core.Dockerfile b/docker/core.Dockerfile index fe323113..7abb1088 100644 --- a/docker/core.Dockerfile +++ b/docker/core.Dockerfile @@ -63,7 +63,7 @@ ENV PATH="/opt/venv/bin:/usr/local/bin:$PATH" \ PI_BIN=pi \ HOME=/home/thoth -COPY docker/core-entrypoint.sh /app/docker/core-entrypoint.sh +COPY docker/core-entrypoint.sh docker/ensure-pi-trust.mjs /app/docker/ RUN chmod +x /app/docker/core-entrypoint.sh WORKDIR /app/backend diff --git a/docker/ensure-pi-trust.mjs b/docker/ensure-pi-trust.mjs new file mode 100644 index 00000000..3b2b56ed --- /dev/null +++ b/docker/ensure-pi-trust.mjs @@ -0,0 +1,30 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export function ensurePiTrust(harnessDir, agentDir = join(homedir(), ".pi", "agent")) { + const trustPath = join(agentDir, "trust.json"); + let trust = {}; + try { + trust = JSON.parse(readFileSync(trustPath, "utf8")); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + if (!trust || typeof trust !== "object" || Array.isArray(trust)) { + throw new Error(`Invalid Pi trust store: ${trustPath}`); + } + + const canonicalHarness = resolve(harnessDir); + if (trust[canonicalHarness] === true) return false; + + mkdirSync(dirname(trustPath), { recursive: true }); + const next = { ...trust, [canonicalHarness]: true }; + const temporaryPath = `${trustPath}.${process.pid}.tmp`; + writeFileSync(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporaryPath, trustPath); + return true; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + ensurePiTrust(process.argv[2] ?? process.env.THT_HARNESS_DIR ?? "/app/harness"); +} diff --git a/docker/ensure-pi-trust.test.mjs b/docker/ensure-pi-trust.test.mjs new file mode 100644 index 00000000..f5b6b7ec --- /dev/null +++ b/docker/ensure-pi-trust.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { ensurePiTrust } from "./ensure-pi-trust.mjs"; + +test("adds the canonical harness path and preserves existing trust entries", () => { + const root = mkdtempSync(join(tmpdir(), "thothii-pi-trust-")); + const agentDir = join(root, "agent"); + ensurePiTrust("/first/harness", agentDir); + writeFileSync( + join(agentDir, "trust.json"), + `${JSON.stringify({ "/existing/project": true }, null, 2)}\n`, + ); + + assert.equal(ensurePiTrust("/app/harness/../harness", agentDir), true); + const trust = JSON.parse(readFileSync(join(agentDir, "trust.json"), "utf8")); + assert.deepEqual(trust, { "/existing/project": true, "/app/harness": true }); + assert.equal(ensurePiTrust("/app/harness", agentDir), false); +}); diff --git a/docs/superpowers/plans/2026-07-14-model-activity-layout-and-composer-state.md b/docs/superpowers/plans/2026-07-14-model-activity-layout-and-composer-state.md new file mode 100644 index 00000000..25936205 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-model-activity-layout-and-composer-state.md @@ -0,0 +1,274 @@ +# Model activity layout and composer state Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make activity updates readable, use the full application width as a 40/60 Model-activity/chat layout while activity is open, and reserve green input highlighting for genuine user-input states. + +**Architecture:** Keep `AppShell` as the owner of the transient activity-panel and composer state. `ModelActivityPanel` converts the known punctuation-boundary stream-concatenation case into Markdown paragraphs before rendering. The composer receives an explicit boolean from `AppShell`; widgets keep their existing independently highlighted textareas. + +**Tech Stack:** React 18, TypeScript, Tailwind CSS, Zustand, Vitest, Testing Library, MSW. + +## Global Constraints + +- Preserve the existing SSE and persisted-session contracts; this is frontend-only. +- UI chrome and test names stay English; Italian stream content is rendered unchanged except for paragraph separation. +- When Model activity is open, it occupies 40% and the conversation 60% of the app area; the session rail is not rendered. +- The normal composer is white. It is green only after **New session** begins question entry and while a pending `freetext` widget awaits a response. +- Existing widget textareas with `data-awaiting-input="true"` remain green whenever rendered. +- Run `npx vitest run` and `npx tsc -b` from `frontend/` before claiming completion. + +--- + +## File structure + +- `frontend/src/shell/ModelActivityPanel.tsx` owns activity Markdown normalization and the left panel's width. +- `frontend/src/shell/ModelActivityPanel.test.tsx` verifies activity rendering and the sentence-boundary regression. +- `frontend/src/shell/AppShell.tsx` owns conditional 40/60 shell layout, hides the right rail, and passes the composer state. +- `frontend/src/shell/AppShell.new-session.test.tsx` verifies shell layout switches and question-entry highlighting. +- `frontend/src/shell/SteerInput.tsx` renders the composer according to an explicit `awaitingInput` prop. +- `frontend/src/shell/SteerInput.test.tsx` verifies white default and explicit green composer states. + +### Task 1: Preserve readable activity message boundaries + +**Files:** +- Modify: `frontend/src/shell/ModelActivityPanel.tsx:10-17` +- Modify: `frontend/src/shell/ModelActivityPanel.test.tsx:1-62` + +**Interfaces:** +- Consumes: `useSessionStore((s) => s.transcript)`, whose entries provide `text: string`. +- Produces: `formatModelActivity(text: string): string`, returning normalized Markdown with blank lines between independent sentences concatenated without whitespace. + +- [ ] **Step 1: Write the failing regression test** + + Add this test after `paragraphs stay separated as distinct blocks`: + + ```tsx + test("separates activity updates concatenated after sentence punctuation", () => { + useSessionStore.getState().applyEvent({ + type: "text_delta", + text: "Ambiguità principale risolta.Finestra temporale risolta.Terza ambiguità risolta.", + }); + render(); + + const first = screen.getByText("Ambiguità principale risolta."); + const second = screen.getByText("Finestra temporale risolta."); + const third = screen.getByText("Terza ambiguità risolta."); + expect(first.tagName).toBe("P"); + expect(second.tagName).toBe("P"); + expect(third.tagName).toBe("P"); + }); + ``` + +- [ ] **Step 2: Run the focused test to verify it fails** + + Run: `npx vitest run src/shell/ModelActivityPanel.test.tsx -t "concatenated after sentence punctuation"` + + Expected: FAIL because the three strings are rendered as one paragraph and exact individual text matches cannot be found. + +- [ ] **Step 3: Add the minimal Markdown normalization** + + In `formatModelActivity`, immediately after newline normalization, add the punctuation rule below. It targets only a sentence-ending `.`/`!`/`?` immediately followed by an uppercase Italian/Latin letter, which is the malformed streamed-update signature; it does not affect ordinary spaces, lowercase continuations, or Markdown lists. + + ```ts + return text + .replace(/\r\n?/g, "\n") + .replace(/([.!?])(?=[A-ZÀ-ÖØ-Þ])/g, "$1\n\n") + .replace(/^[\t ]*[•‣–]\s+/gm, "- ") + ``` + +- [ ] **Step 4: Run the focused panel suite** + + Run: `npx vitest run src/shell/ModelActivityPanel.test.tsx` + + Expected: PASS, including existing Markdown and collapsed-tail behavior. + +- [ ] **Step 5: Commit the activity formatting task** + + ```bash + git add frontend/src/shell/ModelActivityPanel.tsx frontend/src/shell/ModelActivityPanel.test.tsx + git commit -m "fix(frontend): separate concatenated model activity updates" + ``` + +### Task 2: Switch the shell between normal and 40/60 activity layout + +**Files:** +- Modify: `frontend/src/shell/AppShell.tsx:280-365` +- Modify: `frontend/src/shell/AppShell.new-session.test.tsx:1-90` + +**Interfaces:** +- Consumes: local `showActivity: boolean` and `toggleActivity()` in `AppShell`. +- Produces: `data-activity-layout="open" | "closed"` on the shell root; the activity `aside` has `w-2/5`, the conversation column has `w-3/5`, and the right sessions `aside` renders only when `showActivity` is false. + +- [ ] **Step 1: Write the failing layout behavior test** + + Add this test to `AppShell.new-session.test.tsx`: + + ```tsx + test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => { + renderShell(); + + expect(screen.getByText("Sessions")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); + + const shell = screen.getByTestId("app-shell"); + expect(shell).toHaveAttribute("data-activity-layout", "open"); + expect(screen.getByRole("heading", { name: "Model activity" }).closest("aside")).toHaveClass("w-2/5"); + expect(screen.queryByText("Sessions")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /hide model activity/i })); + expect(shell).toHaveAttribute("data-activity-layout", "closed"); + expect(screen.getByText("Sessions")).toBeInTheDocument(); + }); + ``` + +- [ ] **Step 2: Run the focused test to verify it fails** + + Run: `npx vitest run src/shell/AppShell.new-session.test.tsx -t "40/60 activity and chat layout"` + + Expected: FAIL because the root has no test id/layout marker, Model activity is not reachable without an active session, and the right rail remains mounted. + +- [ ] **Step 3: Make Model activity available and conditionally lay out the shell** + + Update `AppShell` as follows: + + ```tsx +
+ {showActivity && setShowActivity(false)} />} +
+ ``` + + Remove the `activeSessionId &&` condition around the header toggle so it is available in the landing view. Change the panel root in `ModelActivityPanel.tsx` from `w-[30vw] max-w-[30vw]` to `w-2/5 shrink-0`; the panel and conversation widths then exactly fill the shell. Finally, wrap the existing right-session-rail `aside` in `!showActivity && (...)` so it is unmounted while the activity panel is open. + +- [ ] **Step 4: Run the focused shell suite** + + Run: `npx vitest run src/shell/AppShell.new-session.test.tsx` + + Expected: PASS, including composer focus and provisional session creation tests. + +- [ ] **Step 5: Commit the layout task** + + ```bash + git add frontend/src/shell/AppShell.tsx frontend/src/shell/ModelActivityPanel.tsx frontend/src/shell/AppShell.new-session.test.tsx + git commit -m "feat(frontend): use full width for open model activity" + ``` + +### Task 3: Make composer highlighting explicit and input-driven + +**Files:** +- Modify: `frontend/src/shell/SteerInput.tsx:10-26,112-121` +- Modify: `frontend/src/shell/AppShell.tsx:34-45,242-267,344-354` +- Modify: `frontend/src/shell/SteerInput.test.tsx:1-74` +- Modify: `frontend/src/shell/AppShell.new-session.test.tsx:28-44` + +**Interfaces:** +- Consumes: `AppShell` local `awaitingQuestion: boolean` and `pendingWidget?.widget` from Zustand. +- Produces: optional `SteerInput` prop `awaitingInput?: boolean`; its textarea has `data-awaiting-input="true"` and `thot-awaiting-input` exactly when that prop is true. + +- [ ] **Step 1: Write the failing component tests** + + Add these tests to `SteerInput.test.tsx`: + + ```tsx + test("keeps the composer white by default", () => { + render(); + expect(screen.getByRole("textbox", { name: /new question/i })).not.toHaveAttribute("data-awaiting-input"); + expect(screen.getByRole("textbox", { name: /new question/i })).not.toHaveClass("thot-awaiting-input"); + }); + + test("marks the composer as awaiting input only when requested", () => { + render(); + expect(screen.getByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true"); + expect(screen.getByRole("textbox", { name: /new question/i })).toHaveClass("thot-awaiting-input"); + }); + ``` + + Extend the existing New-session focus test with: + + ```tsx + expect(composer).toHaveAttribute("data-awaiting-input", "true"); + expect(composer).toHaveClass("thot-awaiting-input"); + ``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + + Run: `npx vitest run src/shell/SteerInput.test.tsx src/shell/AppShell.new-session.test.tsx -t "composer|New session starts"` + + Expected: FAIL because a landing composer currently always has `data-awaiting-input="true"` and `SteerInput` has no `awaitingInput` prop. + +- [ ] **Step 3: Add the explicit question-entry state and prop** + + Add `awaitingInput?: boolean` to `SteerInput` props and replace the textarea attributes/classes with: + + ```tsx + data-awaiting-input={awaitingInput ? "true" : undefined} + className={[ + "max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", + awaitingInput && "thot-awaiting-input", + ].filter(Boolean).join(" ")} + ``` + + In `AppShell`, initialize `const [awaitingQuestion, setAwaitingQuestion] = useState(false)`. Set it to true in `startNewSession`; set it to false in `beginSessionCreation`, `finishSessionCreation`, `stopSession`, `doResume`, and the `session_exit` effect. Keep it true on `failSessionCreation` so the retained question remains visibly ready to retry. Pass the prop to the composer: + + ```tsx + awaitingInput={awaitingQuestion || pendingWidget?.widget === "freetext"} + ``` + + Do not change `FreetextWidget.tsx` or `ReservedControls.tsx`: their rendered textareas already accurately signal a required user response. + +- [ ] **Step 4: Run focused input and shell tests** + + Run: `npx vitest run src/shell/SteerInput.test.tsx src/shell/AppShell.new-session.test.tsx` + + Expected: PASS. The initial landing composer and active-session steering input are white; New session and a pending freetext gate are green. + +- [ ] **Step 5: Commit the composer-state task** + + ```bash + git add frontend/src/shell/AppShell.tsx frontend/src/shell/SteerInput.tsx frontend/src/shell/SteerInput.test.tsx frontend/src/shell/AppShell.new-session.test.tsx + git commit -m "fix(frontend): highlight composer only when input is needed" + ``` + +### Task 4: Verify the integrated frontend change + +**Files:** +- Modify only if verification exposes a TypeScript or test issue in the files listed above. + +**Interfaces:** +- Consumes: the completed shell, activity-panel, and composer contracts from Tasks 1–3. +- Produces: validated frontend behavior with no API or persistence changes. + +- [ ] **Step 1: Run the entire frontend test suite** + + Run: `npx vitest run` + + Expected: PASS with no failed test files. + +- [ ] **Step 2: Run the frontend typecheck** + + Run: `npx tsc -b` + + Expected: exit code 0 and no TypeScript diagnostics. + +- [ ] **Step 3: Inspect the final working-tree diff** + + Run: `git diff --check && git status --short` + + Expected: no whitespace errors. Confirm that only the planned frontend files and this plan/spec are present among this task's changes; preserve all unrelated pre-existing modifications. + +- [ ] **Step 4: Commit verification-only follow-up, if needed** + + If Steps 1–3 required a corrective code or test change, stage only that correction and commit it with: + + ```bash + git add + git commit -m "test(frontend): verify activity layout and composer states" + ``` + + If no corrective change was required, do not create an empty commit. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d602ba13..58db46a1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,7 +12,11 @@ export async function apiFetch(path: string, init?: RequestInit): Promise } const res = await fetch(joinBackendPath(BASE, path), { ...init, headers }); if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => "")}`); - return res.status === 204 ? (undefined as T) : ((await res.json()) as T); + if (res.status === 204) return undefined as T; + // Accepted fire-and-forget endpoints may legitimately return 202 with no + // representation. Keep apiFetch useful for both 202 and 204 contracts. + const body = await res.text(); + return body ? (JSON.parse(body) as T) : (undefined as T); } export { BASE }; diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index 06c362fa..a681079e 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; -import { createSession, listSessions } from "./sessions"; +import { createSession, listSessions, prewarmRuntime } from "./sessions"; import { renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, getSessionDocuments, @@ -18,6 +18,18 @@ test("createSession POSTs only {question} and returns the id", async () => { expect(body).toEqual({ question: "q" }); }); +test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => { + let called = false; + server.use( + http.post("http://localhost:8787/runtime/prewarm", () => { + called = true; + return new HttpResponse(null, { status }); + }), + ); + await expect(prewarmRuntime()).resolves.toBeUndefined(); + expect(called).toBe(true); +}); + test("listSessions GETs the array", async () => { server.use(http.get("http://localhost:8787/sessions", () => HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }]))); const rows = await listSessions(); diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index f3a88b7d..6d947291 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -4,6 +4,10 @@ import type { SessionSummary, SessionDocument, UiResponse } from "./types"; export const createSession = (i: { question: string; name?: string }) => apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) }); +/** Best-effort warm-up; callers must not await it before showing the composer. */ +export const prewarmRuntime = () => + apiFetch("/runtime/prewarm", { method: "POST" }); + export const listSessions = () => apiFetch("/sessions"); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/frontend/src/index.css b/frontend/src/index.css index e892e0fa..337322d1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -221,3 +221,19 @@ color: oklch(var(--muted-foreground)); } } + + +@layer components { + .thot-awaiting-input { + background: oklch(var(--success) / 0.14); + border-color: oklch(var(--success) / 0.55); + } + .thot-awaiting-input:focus { + box-shadow: 0 0 0 3px oklch(var(--success) / 0.18); + } + .thot-prose p + p { margin-top: 1em; } + .thot-prose ul { list-style: disc; } + .thot-prose ol { list-style: decimal; } + .thot-prose ul ul { list-style: circle; } + .thot-prose ol ol { list-style: lower-alpha; } +} diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx new file mode 100644 index 00000000..fd773ced --- /dev/null +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse, delay } from "msw"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { server } from "../test/msw"; +import { FakeEventSource } from "../test/fakeEventSource"; +import { useSessionStore } from "../store/sessionStore"; +import { AppShell } from "./AppShell"; + +function renderShell() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(); +} + +beforeEach(() => { + FakeEventSource.instances = []; + (globalThis as any).EventSource = FakeEventSource; + useSessionStore.getState().resetSession(); + server.use( + http.get("http://localhost:8787/sessions", () => HttpResponse.json([])), + http.get("http://localhost:8787/settings", () => + HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })), + http.get("http://localhost:8787/workspaces", () => HttpResponse.json([])), + http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })), + ); +}); + +test("New session starts prewarm without delaying composer focus", async () => { + let prewarmStarted = false; + server.use( + http.post("http://localhost:8787/runtime/prewarm", async () => { + prewarmStarted = true; + await delay(100); + return new HttpResponse(null, { status: 202 }); + }), + ); + renderShell(); + + await userEvent.click(screen.getByRole("button", { name: /^new session$/i })); + + const composer = screen.getByRole("textbox", { name: /new question/i }); + await waitFor(() => expect(prewarmStarted).toBe(true)); + await waitFor(() => expect(composer).toHaveFocus()); +}); + +test("shows the question and creation timer before POST /sessions resolves, then opens SSE", async () => { + let releaseCreate!: () => void; + const createMayFinish = new Promise((resolve) => { releaseCreate = resolve; }); + server.use( + http.post("http://localhost:8787/sessions", async () => { + await createMayFinish; + return HttpResponse.json({ id: "s-new" }); + }), + ); + renderShell(); + + const composer = screen.getByRole("textbox", { name: /new question/i }); + await userEvent.type(composer, "How many patients?"); + await userEvent.click(screen.getByRole("button", { name: /send/i })); + + expect(await screen.findByText("How many patients?", { selector: "p" })).toBeInTheDocument(); + expect(screen.getByText("Creating session…")).toBeInTheDocument(); + expect(screen.getByText(/\d+s/)).toBeInTheDocument(); + expect(FakeEventSource.instances).toHaveLength(0); + + releaseCreate(); + await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); + expect(screen.getByText("How many patients?", { selector: "p" })).toBeInTheDocument(); + expect(screen.getByText("Analyzing question…")).toBeInTheDocument(); + expect(useSessionStore.getState().currentPhase).toBe("F1"); +}); + +test("a failed create restores the landing view and preserves the question for retry", async () => { + server.use( + http.post("http://localhost:8787/sessions", () => + new HttpResponse("unavailable", { status: 503 })), + ); + renderShell(); + + const composer = screen.getByRole("textbox", { name: /new question/i }); + await userEvent.type(composer, "Keep this question"); + await userEvent.click(screen.getByRole("button", { name: /send/i })); + + expect(await screen.findByText(/failed to create session/i)).toBeInTheDocument(); + expect(screen.getByText(/type your question/i)).toBeInTheDocument(); + expect(composer).toHaveValue("Keep this question"); + expect(useSessionStore.getState().lastUserEntry).toBeNull(); + expect(FakeEventSource.instances).toHaveLength(0); +}); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 263ef669..ea710303 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -11,13 +11,14 @@ import { DeleteConfirmDialog } from "./DeleteConfirmDialog"; import { StopConfirmDialog } from "./StopConfirmDialog"; import { SteerInput, ComposerFooter } from "./SteerInput"; import { WorkflowBar } from "./WorkflowBar"; -import { Pencil, ArrowLeft, ArrowRight } from "lucide-react"; +import { Pencil, ArrowLeft, ArrowRight, Trash2 } from "lucide-react"; import { Button } from "../components/ui/button"; +import { Checkbox } from "../components/ui/checkbox"; import { Toaster } from "../components/ui/sonner"; import { toast } from "sonner"; import { closeSession, listSessions, resumeSession, getSession, - renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, + renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime, } from "../api/sessions"; import type { SessionSummary } from "../api/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -31,6 +32,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; */ export function AppShell() { const [activeSessionId, setActiveSessionId] = useState(null); + const [creatingSession, setCreatingSession] = useState(false); const { data: sessions = [] } = useQuery({ queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000, }); @@ -42,7 +44,8 @@ export function AppShell() { const [activeOpen, setActiveOpen] = useState(true); const [archiveOpen, setArchiveOpen] = useState(false); const [renameTarget, setRenameTarget] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteTargets, setDeleteTargets] = useState([]); + const [selectedSessionIds, setSelectedSessionIds] = useState>(new Set()); const [stopConfirm, setStopConfirm] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState>({}); const [renameGroupTarget, setRenameGroupTarget] = useState(null); @@ -57,6 +60,31 @@ export function AppShell() { const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] }); const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null; const finalized = activeSession?.status === "finalized"; + const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id)); + const allSessionsSelected = sessions.length > 0 && selectedSessions.length === sessions.length; + + // A background refresh can remove a session (for example from another browser). + // Keep the local selection aligned with the authoritative list. + useEffect(() => { + const availableIds = new Set(sessions.map((session) => session.id)); + setSelectedSessionIds((current) => { + const next = new Set([...current].filter((id) => availableIds.has(id))); + return next.size === current.size ? current : next; + }); + }, [sessions]); + + function setSessionSelected(id: string, selected: boolean) { + setSelectedSessionIds((current) => { + const next = new Set(current); + if (selected) next.add(id); + else next.delete(id); + return next; + }); + } + + function toggleAllSessions(selected: boolean) { + setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set()); + } function openPanel(id: string) { const s = sessions.find((x) => x.id === id); @@ -138,6 +166,24 @@ export function AppShell() { } } + async function deleteSessions(targets: SessionSummary[]) { + try { + const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id))); + const deletedIds = new Set( + targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id), + ); + if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null); + if (deletedIds.has(activeSessionId ?? "")) { resetSession(); setActiveSessionId(null); } + setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id)))); + refresh(); + if (deletedIds.size !== targets.length) { + toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`); + } + } catch { + toast.error("Failed to delete selected sessions."); + } + } + function menuFor(s: SessionSummary) { return ( move(s, g)} onNewGroup={() => newGroup(s)} onArchiveToggle={() => toggleArchive(s)} - onDelete={() => setDeleteTarget(s)} + onDelete={() => { void deleteSessions([s]); }} /> ); } @@ -164,7 +210,8 @@ export function AppShell() { const setAgentActive = useSessionStore((s) => s.setAgentActive); const lastSystemEvent = useSessionStore((s) => s.lastSystemEvent); const agentActive = useSessionStore((s) => s.agentActive); - const working = Boolean(activeSessionId) && !pendingWidget && agentActive; + const sessionViewOpen = Boolean(activeSessionId) || creatingSession; + const working = sessionViewOpen && !pendingWidget && agentActive; // Processing time counts only while the harness works, not while a finalized // session sits idle or a gate awaits the reviewer (pendingWidget). const running = working && !finalized; @@ -179,7 +226,12 @@ export function AppShell() { useEffect(() => { if (lastSystemEvent?.type !== "system_event") return; const ev = (lastSystemEvent as any).event; - if (ev === "session_exit") stopSession(); + if (ev === "session_exit") { + // Never let a streamed event terminate the managed Pi child. Only the + // explicit “Stop & save” action is allowed to call /close. + resetSession(); + setActiveSessionId(null); + } // The final workflow turn ends with the session already finalized on disk: // refetch now instead of waiting for the 10s poll, so the completed state // (and the new-session invite below the transcript) appears immediately. @@ -189,10 +241,32 @@ export function AppShell() { function startNewSession() { resetSession(); + setCreatingSession(false); setActiveSessionId(null); + // Best effort only: session creation keeps the authoritative readiness gate. + // Composer focus is deliberately independent of this network request. + void prewarmRuntime().catch(() => undefined); requestAnimationFrame(() => composerRef.current?.focus()); } + function beginSessionCreation() { + setCreatingSession(true); + } + + function finishSessionCreation(id: string) { + // React batches these updates, preserving the provisional view and timer + // while useSessionStream opens the durable session's SSE channel. + setActiveSessionId(id); + setCreatingSession(false); + refresh(); + } + + function failSessionCreation() { + setCreatingSession(false); + resetSession(); + toast.error("Failed to create session. Your question is ready to retry."); + } + async function stopSession() { if (!activeSessionId) return; try { @@ -241,10 +315,13 @@ export function AppShell() {
- {activeSessionId ? ( + {sessionViewOpen ? ( <> - - + + {activeSessionId && } {finalized && !agentActive && (

@@ -270,7 +347,9 @@ export function AppShell() {

setStopConfirm(true)} inputRef={composerRef} /> @@ -311,6 +390,32 @@ export function AppShell() { Sessions
+ +
+ + {selectedSessions.length > 0 && ( + + )} +
{/* L2 — section toggle */}
@@ -365,6 +472,8 @@ export function AppShell() { activeSessionId={activeSessionId} onOpenPanel={openPanel} menuFor={menuFor} + selectedIds={selectedSessionIds} + onSelectionChange={setSessionSelected} /> )}
@@ -381,7 +490,14 @@ export function AppShell() { Archive ({archivedList.length}) {archiveOpen && ( - + )}
@@ -419,21 +535,15 @@ export function AppShell() { title="Rename group" /> )} - {deleteTarget && ( + {deleteTargets.length > 0 && ( { if (!o) setDeleteTarget(null); }} - onConfirm={async () => { - try { - await deleteSession(deleteTarget.id); - if (panelSession?.id === deleteTarget.id) setPanelSession(null); - if (activeSessionId === deleteTarget.id) { resetSession(); setActiveSessionId(null); } - setDeleteTarget(null); - refresh(); - } catch { - toast.error("Failed to delete session."); - } + labels={deleteTargets.map((session) => session.name || session.question || session.id)} + onOpenChange={(o) => { if (!o) setDeleteTargets([]); }} + onConfirm={() => { + const targets = deleteTargets; + setDeleteTargets([]); + void deleteSessions(targets); }} /> )} diff --git a/frontend/src/shell/CentralStatus.tsx b/frontend/src/shell/CentralStatus.tsx index aa5d7ffe..e434a9c9 100644 --- a/frontend/src/shell/CentralStatus.tsx +++ b/frontend/src/shell/CentralStatus.tsx @@ -17,7 +17,7 @@ function tailLines(s: string, n = 5): string[] { * (elapsed time + a short tail of the model stream) while the model is busy, and the * gate's curated messages for the current step. The full verbose stream lives in the * left Model-activity panel. */ -export function CentralStatus({ working }: { working: boolean }) { +export function CentralStatus({ working, workingLabel }: { working: boolean; workingLabel?: string }) { const lastUserEntry = useSessionStore((s) => s.lastUserEntry); const stepMessages = useSessionStore((s) => s.stepMessages); const transcript = useSessionStore((s) => s.transcript); @@ -62,7 +62,7 @@ export function CentralStatus({ working }: { working: boolean }) { {elapsed}s - ThothII is working + {workingLabel ?? "ThothII is working"}
{tailRows.length > 0 && (
    diff --git a/frontend/src/shell/DeleteConfirmDialog.tsx b/frontend/src/shell/DeleteConfirmDialog.tsx index f1c0622b..9b5b07c0 100644 --- a/frontend/src/shell/DeleteConfirmDialog.tsx +++ b/frontend/src/shell/DeleteConfirmDialog.tsx @@ -3,18 +3,24 @@ import { Button } from "../components/ui/button"; interface Props { open: boolean; - label: string; + labels?: string[]; + /** Backward-compatible single-session label. */ + label?: string; onOpenChange: (open: boolean) => void; onConfirm: () => void; } -export function DeleteConfirmDialog({ open, label, onOpenChange, onConfirm }: Props) { +export function DeleteConfirmDialog({ open, labels, label, onOpenChange, onConfirm }: Props) { + const targets = labels ?? (label ? [label] : []); + const description = targets.length > 1 + ? `${targets.length} sessions will be permanently deleted, including all of their documents. This action cannot be undone.` + : `"${targets[0]}" will be permanently deleted, including all of its documents. This action cannot be undone.`; return ( Delete permanently - "{label}" will be permanently deleted, including all of its documents. This action cannot be undone. + {description} diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index 84c8179f..b015fad5 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -7,6 +7,16 @@ import { Button } from "../components/ui/button"; const TAIL_PARAGRAPHS = 5; +export function formatModelActivity(text: string): string { + return text + .replace(/\r\n?/g, "\n") + .replace(/^[\t ]*[•‣–]\s+/gm, "- ") + .replace(/^(\d+)\)\s+/gm, (_, n) => `${n}. `) + .replace(/\n(?=\S)(?![-*+] |\d+\. |>|`|\|)/g, "\n\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + /** Left drawer showing "what's happening": the tail of the model's streamed markdown * output, rendered as markdown (not raw text) so headings/emphasis/lists come through * and paragraphs stay visually separated. Collapsed it shows the last few paragraphs @@ -27,7 +37,7 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { .filter((p) => p !== ""); const shown = expanded ? paragraphs : paragraphs.slice(-TAIL_PARAGRAPHS); const canExpand = paragraphs.length > TAIL_PARAGRAPHS; - const source = shown.join("\n\n"); + const source = formatModelActivity(shown.join("\n\n")); return (