From 0cd14b5b555c47829b66cebf6ccc3bf78cceac58 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 15:59:47 +0200 Subject: [PATCH] docs: settings-menu design spec + implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-28-settings-menu.md | 1506 +++++++++++++++++ .../specs/2026-06-28-settings-menu-design.md | 141 ++ 2 files changed, 1647 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-settings-menu.md create mode 100644 docs/superpowers/specs/2026-06-28-settings-menu-design.md diff --git a/docs/superpowers/plans/2026-06-28-settings-menu.md b/docs/superpowers/plans/2026-06-28-settings-menu.md new file mode 100644 index 00000000..5ee6ca19 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-settings-menu.md @@ -0,0 +1,1506 @@ +# Settings Menu 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:** Move workspace/provider/model/thinking out of the "Nuova domanda" form into a global, backend-persisted **Settings** dialog reachable from a new sidebar menu item; populate the choices from real sources (workspace YAMLs and Pi's `get_available_models`). + +**Architecture:** The backend persists settings to a JSON file and applies them when creating a session (the frontend no longer sends these per-question). A new ephemeral-Pi model lister answers `GET /models` with real `{provider,id,name,reasoning}` objects. The frontend gets a `SettingsDialog` (4 selects) and the `NewSessionDialog` shrinks to a single question field. + +**Tech Stack:** Backend = Fastify 5 + TypeScript (ESM, `.js` import specifiers), Vitest. Frontend = React 18 + @tanstack/react-query + @base-ui/react + Tailwind, Vitest + Testing Library + MSW. + +## Global Constraints + +- Backend is ESM/NodeNext: **all relative imports use `.js` specifiers** even from `.ts` files (e.g. `import { loadSettings } from "../settings/settings-store.js"`). +- `tht` CLI `--config`/`-c` is **per-command**, appended AFTER the subcommand — already handled by `ThtRunner`; do not change that. +- Pi RPC `get_available_models` reply envelope is `{ id, type:"response", command:"get_available_models", success:true, data:{ models:[...] } }`. Each model is `{ provider:string, id:string, name:string, reasoning:boolean, ... }`. Read `resp.data.models`. +- Pi `set_model` takes `{ type:"set_model", provider, modelId }` (model id, not name). Thinking via `{ type:"set_thinking_level", level }`. +- Thinking levels exposed in the UI: exactly `low`, `medium`, `high` (Pi also supports off/minimal/xhigh — do NOT expose them here). +- Settings file default path: `data/settings.json` relative to the backend process cwd; override via `SETTINGS_FILE` env. It must be gitignored. +- Backend test command: `cd backend && npx vitest run `. Frontend test command: `cd frontend && npx vitest run `. +- Run tests from inside the respective package dir (`backend/` or `frontend/`), never the repo root. + +--- + +## File Structure + +**Backend** +- Create `backend/src/settings/settings-store.ts` — load/save settings JSON + effective-defaults merge. +- Create `backend/src/pi/list-models.ts` — ephemeral Pi spawn → `get_available_models` → `PiModel[]`, with TTL cache. +- Create `backend/src/routes/settings.ts` — `GET /settings`, `PUT /settings`. +- Modify `backend/src/config.ts` — add `settingsFile`. +- Modify `backend/src/routes/meta.ts` — export `listWorkspaces`; `ListModelsFn` returns `PiModel[]`. +- Modify `backend/src/app.ts` — wire settings store, real model lister, settings routes, inject `getSettings` into session routes. +- Modify `backend/src/routes/sessions.ts` — `POST /sessions` reads settings instead of body params. +- Modify `backend/.gitignore` (or repo `.gitignore`) — ignore the settings file. +- Tests: `backend/test/settings-store.test.ts`, `backend/test/list-models.test.ts`, `backend/test/routes-settings.test.ts`; update `backend/test/routes-sessions.test.ts` and `backend/test/routes-sql-meta.test.ts`. + +**Frontend** +- Modify `frontend/src/api/models.ts` — typed `PiModel`, `listModels` returns `{ models: PiModel[] }`. +- Create `frontend/src/api/settings.ts` — `getSettings`, `putSettings`. +- Modify `frontend/src/api/sessions.ts` — `createSession({ question, name? })`. +- Modify `frontend/src/shell/NewSessionDialog.tsx` — question-only form. +- Create `frontend/src/shell/SettingsDialog.tsx` — the settings form. +- Modify `frontend/src/shell/AppShell.tsx` — add the Settings button. +- Tests: `frontend/src/shell/SettingsDialog.test.tsx`; update `frontend/src/shell/NewSessionDialog.test.tsx` and `frontend/src/api/sessions.test.ts`. + +--- + +## Task 1: Backend settings store + config + +**Files:** +- Create: `backend/src/settings/settings-store.ts` +- Modify: `backend/src/config.ts` +- Modify: `backend/.gitignore` (create if missing) +- Test: `backend/test/settings-store.test.ts` + +**Interfaces:** +- Consumes: `AppConfig` from `config.ts`. +- Produces: + - `interface Settings { workspace?: string; provider?: string; model?: string; thinking?: string }` + - `loadSettings(cfg: AppConfig): Settings` — reads `cfg.settingsFile` JSON; `{}` if missing/invalid. + - `saveSettings(cfg: AppConfig, s: Settings): Settings` — `mkdir -p` parent, write pretty JSON, return `s`. + - `config.ts` `AppConfig` gains `settingsFile: string`; `loadConfig` sets it from `env.SETTINGS_FILE ?? "data/settings.json"`. + +- [ ] **Step 1: Write the failing test** + +Create `backend/test/settings-store.test.ts`: + +```ts +import { test, expect } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadSettings, saveSettings } from "../src/settings/settings-store.js"; +import { loadConfig } from "../src/config.js"; + +function cfgWith(file: string) { + return loadConfig({ THT_HARNESS_DIR: "../harness", SETTINGS_FILE: file }); +} + +test("loadSettings returns {} when the file does not exist", () => { + const dir = mkdtempSync(join(tmpdir(), "tht-set-")); + try { + expect(loadSettings(cfgWith(join(dir, "settings.json")))).toEqual({}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("saveSettings writes the file and loadSettings reads it back", () => { + const dir = mkdtempSync(join(tmpdir(), "tht-set-")); + try { + const cfg = cfgWith(join(dir, "nested", "settings.json")); + const saved = saveSettings(cfg, { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" }); + expect(saved.model).toBe("glm-5.2"); + expect(loadSettings(cfg)).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadSettings returns {} on corrupt JSON (no throw)", () => { + const dir = mkdtempSync(join(tmpdir(), "tht-set-")); + try { + const file = join(dir, "settings.json"); + writeFileSync(file, "{ not json"); + expect(loadSettings(cfgWith(file))).toEqual({}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadConfig sets settingsFile from SETTINGS_FILE, default data/settings.json", () => { + expect(loadConfig({}).settingsFile).toBe("data/settings.json"); + expect(loadConfig({ SETTINGS_FILE: "/x/y.json" }).settingsFile).toBe("/x/y.json"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run test/settings-store.test.ts` +Expected: FAIL — cannot find module `../src/settings/settings-store.js` and `settingsFile` undefined. + +- [ ] **Step 3: Add `settingsFile` to config** + +In `backend/src/config.ts`, add the field to the interface and to the returned object: + +```ts +export interface AppConfig { + port: number; harnessDir: string; thtBin: string; piBin: string; + authMode: "none" | "mock" | "oidc"; + defaults: { provider?: string; model?: string; thinking?: string }; + maxPiProcesses: number; + settingsFile: string; +} +``` + +and inside the returned object in `loadConfig`, after `maxPiProcesses`: + +```ts + maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4), + settingsFile: env.SETTINGS_FILE ?? "data/settings.json", +``` + +- [ ] **Step 4: Implement the store** + +Create `backend/src/settings/settings-store.ts`: + +```ts +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { AppConfig } from "../config.js"; + +export interface Settings { + workspace?: string; + provider?: string; + model?: string; + thinking?: string; +} + +/** Read settings from cfg.settingsFile. Returns {} if missing or invalid. */ +export function loadSettings(cfg: AppConfig): Settings { + try { + const raw = readFileSync(cfg.settingsFile, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed as Settings; + return {}; + } catch { + return {}; + } +} + +/** Persist settings (pretty JSON). Creates the parent directory if needed. */ +export function saveSettings(cfg: AppConfig, s: Settings): Settings { + mkdirSync(dirname(cfg.settingsFile), { recursive: true }); + writeFileSync(cfg.settingsFile, JSON.stringify(s, null, 2) + "\n", "utf8"); + return s; +} +``` + +- [ ] **Step 5: Ignore the settings file** + +Append to `backend/.gitignore` (create the file if it does not exist): + +``` +data/settings.json +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd backend && npx vitest run test/settings-store.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 7: Commit** + +```bash +git add backend/src/settings/settings-store.ts backend/src/config.ts backend/.gitignore backend/test/settings-store.test.ts +git commit -m "feat(backend): persistent settings store + settingsFile config" +``` + +--- + +## Task 2: Ephemeral Pi model lister + +**Files:** +- Create: `backend/src/pi/list-models.ts` +- Test: `backend/test/list-models.test.ts` + +**Interfaces:** +- Consumes: `AppConfig`; `RpcClient` from `../rpc/rpc-client.js`; Node `spawn`. +- Produces: + - `interface PiModel { provider: string; id: string; name: string; reasoning: boolean }` + - `createPiModelLister(cfg: AppConfig, opts?: { spawnFn?: () => ChildProcessWithoutNullStreams; ttlMs?: number; nowMs?: () => number }): () => Promise` + - The returned function spawns `pi --mode rpc`, sends `get_available_models`, parses `resp.data.models`, maps to `PiModel[]`, kills the child, and caches the result for `ttlMs` (default 60000). On timeout (8s) or error it rejects (callers handle the fallback). + +- [ ] **Step 1: Write the failing test** + +Create `backend/test/list-models.test.ts`. It drives the lister against the existing fake Pi (`harness/tests/fake_pi/fake_pi_rpc.mjs`), which already answers `get_available_models` with `{type:"response", command, id, success:true, data:{models: script.available_models}}`. + +```ts +import { test, expect } from "vitest"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import path from "node:path"; +import { createPiModelLister } from "../src/pi/list-models.js"; +import { loadConfig } from "../src/config.js"; + +const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs"); + +function scriptWith(models: unknown[]): string { + const dir = mkdtempSync(join(tmpdir(), "tht-models-")); + const file = join(dir, "models.json"); + writeFileSync(file, JSON.stringify({ available_models: models })); + return file; +} + +test("createPiModelLister returns mapped PiModel[] from get_available_models", async () => { + const script = scriptWith([ + { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true, extra: "ignored" }, + { provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true }, + ]); + try { + const lister = createPiModelLister(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + spawnFn: () => spawn("node", [FAKE, script]) as any, + }); + const models = await lister(); + expect(models).toEqual([ + { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, + { provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true }, + ]); + } finally { + rmSync(path.dirname(script), { recursive: true, force: true }); + } +}); + +test("createPiModelLister caches within ttl (spawns once for two calls)", async () => { + const script = scriptWith([{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }]); + try { + let spawns = 0; + const lister = createPiModelLister(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + spawnFn: () => { spawns++; return spawn("node", [FAKE, script]) as any; }, + ttlMs: 10_000, + nowMs: () => 1000, + }); + await lister(); + await lister(); + expect(spawns).toBe(1); + } finally { + rmSync(path.dirname(script), { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run test/list-models.test.ts` +Expected: FAIL — cannot find module `../src/pi/list-models.js`. + +- [ ] **Step 3: Implement the lister** + +Create `backend/src/pi/list-models.ts`: + +```ts +import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { join } from "node:path"; +import type { AppConfig } from "../config.js"; +import { RpcClient } from "../rpc/rpc-client.js"; + +export interface PiModel { + provider: string; + id: string; + name: string; + reasoning: boolean; +} + +interface Opts { + spawnFn?: () => ChildProcessWithoutNullStreams; + ttlMs?: number; + nowMs?: () => number; +} + +/** + * Returns a function that lists Pi's available models (those with auth + * configured) via an ephemeral `pi --mode rpc` process. Result is cached for + * `ttlMs`. The returned function rejects on timeout/error; callers degrade. + */ +export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise { + const ttlMs = opts.ttlMs ?? 60_000; + const now = opts.nowMs ?? (() => Date.now()); + const spawnFn = + opts.spawnFn ?? + (() => { + const harnessVenvBin = join(cfg.harnessDir, ".venv", "bin"); + return nodeSpawn(cfg.piBin, ["--mode", "rpc"], { + cwd: cfg.harnessDir, + env: { ...process.env, PATH: `${harnessVenvBin}:${process.env.PATH ?? ""}` }, + }) as ChildProcessWithoutNullStreams; + }); + + let cache: { at: number; models: PiModel[] } | null = null; + + return async function listModels(): Promise { + if (cache && now() - cache.at < ttlMs) return cache.models; + + const child = spawnFn(); + child.stderr.resume(); + const rpc = new RpcClient(child); + try { + const resp = await Promise.race([ + rpc.request({ type: "get_available_models" }), + new Promise((_, rej) => setTimeout(() => rej(new Error("pi model list timeout")), 8000)), + ]); + const raw = (resp?.data?.models ?? []) as Array>; + const models: PiModel[] = raw.map((m) => ({ + provider: String(m.provider ?? ""), + id: String(m.id ?? ""), + name: String(m.name ?? m.id ?? ""), + reasoning: Boolean(m.reasoning), + })); + cache = { at: now(), models }; + return models; + } finally { + child.kill(); + } + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run test/list-models.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/pi/list-models.ts backend/test/list-models.test.ts +git commit -m "feat(backend): ephemeral Pi model lister (get_available_models) with TTL cache" +``` + +--- + +## Task 3: `/models` real shape + `/settings` routes + app wiring + +**Files:** +- Modify: `backend/src/routes/meta.ts` +- Create: `backend/src/routes/settings.ts` +- Modify: `backend/src/app.ts` +- Modify: `backend/test/routes-sql-meta.test.ts` (update `/models` expectations to objects) +- Test: `backend/test/routes-settings.test.ts` + +**Interfaces:** +- Consumes: `loadSettings`/`saveSettings`/`Settings` (Task 1), `PiModel` + `createPiModelLister` (Task 2), `AppConfig`. +- Produces: + - `meta.ts`: `export function listWorkspaces(harnessDir: string): { name: string; file: string }[]`; `ListModelsFn = () => Promise`; `GET /models` returns `{ models: PiModel[] }`. + - `settings.ts`: `export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings`; `export function settingsRoutes(app, deps: { cfg: AppConfig; listModels: ListModelsFn }): void` registering `GET /settings` and `PUT /settings`. + - `effectiveSettings` fills: `workspace ?? firstWorkspace(cfg)`, `provider ?? cfg.defaults.provider`, `model ?? cfg.defaults.model`, `thinking ?? cfg.defaults.thinking`. + - `app.ts` `BuildAppDeps` gains `getSettings?: () => Settings`. + +- [ ] **Step 1: Write the failing test** + +Create `backend/test/routes-settings.test.ts`: + +```ts +import { test, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; + +function appWithTmpSettings(extraEnv: Record = {}, deps = {}) { + const dir = mkdtempSync(join(tmpdir(), "tht-set-route-")); + const app = buildApp( + loadConfig({ THT_HARNESS_DIR: "../harness", SETTINGS_FILE: join(dir, "settings.json"), ...extraEnv }), + { thtRunner: {} as any, ...deps }, + ); + return { app, dir }; +} + +test("GET /settings returns effective defaults (env provider/model/thinking, first workspace)", async () => { + const { app, dir } = appWithTmpSettings({ PI_PROVIDER: "zai", PI_MODEL: "glm-5.2", PI_THINKING: "medium" }, { + listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], + }); + try { + const res = await app.inject({ method: "GET", url: "/settings" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.provider).toBe("zai"); + expect(body.model).toBe("glm-5.2"); + expect(body.thinking).toBe("medium"); + expect(typeof body.workspace).toBe("string"); // first workspace from ../harness/workspaces + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PUT /settings persists and GET reads it back", async () => { + const { app, dir } = appWithTmpSettings({}, { + listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], + }); + try { + const put = await app.inject({ + method: "PUT", url: "/settings", + payload: { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }, + }); + expect(put.statusCode).toBe(200); + const got = await app.inject({ method: "GET", url: "/settings" }); + expect(got.json()).toMatchObject({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PUT /settings rejects an unknown model when a model list is available", async () => { + const { app, dir } = appWithTmpSettings({}, { + listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], + }); + try { + const put = await app.inject({ + method: "PUT", url: "/settings", + payload: { workspace: "psd", provider: "zai", model: "does-not-exist", thinking: "low" }, + }); + expect(put.statusCode).toBe(400); + expect(put.json()).toMatchObject({ error: expect.stringMatching(/model/i) }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PUT /settings allows any model when model list is empty (Pi unavailable)", async () => { + const { app, dir } = appWithTmpSettings({}, { listModels: async () => [] }); + try { + const put = await app.inject({ + method: "PUT", url: "/settings", + payload: { workspace: "psd", model: "whatever", thinking: "low" }, + }); + expect(put.statusCode).toBe(200); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run test/routes-settings.test.ts` +Expected: FAIL — no `/settings` route (404), `settings.ts` missing. + +- [ ] **Step 3: Update meta.ts (export listWorkspaces, PiModel-typed models)** + +Replace `backend/src/routes/meta.ts` with: + +```ts +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import type { FastifyInstance } from "fastify"; +import type { PiModel } from "../pi/list-models.js"; + +export type ListModelsFn = () => Promise; + +/** + * List YAML workspace configs found in /workspaces/*.yaml. + * Returns [{name, file}] — no database credentials or secrets. + */ +export function listWorkspaces(harnessDir: string): { name: string; file: string }[] { + const dir = join(harnessDir, "workspaces"); + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; + } + return entries + .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) + .map((f) => ({ name: f.replace(/\.ya?ml$/, ""), file: f })); +} + +export function metaRoutes( + app: FastifyInstance, + deps: { harnessDir: string; listModels?: ListModelsFn }, +): void { + app.get("/workspaces", async () => { + return listWorkspaces(deps.harnessDir); + }); + + app.get("/models", async () => { + const fn = deps.listModels ?? (async () => []); + try { + return { models: await fn() }; + } catch { + // Graceful fallback: Pi may not be running; don't crash the server. + return { models: [] as PiModel[] }; + } + }); +} +``` + +- [ ] **Step 4: Create settings.ts** + +Create `backend/src/routes/settings.ts`: + +```ts +import type { FastifyInstance } from "fastify"; +import type { AppConfig } from "../config.js"; +import { loadSettings, saveSettings, type Settings } from "../settings/settings-store.js"; +import { listWorkspaces, type ListModelsFn } from "./meta.js"; + +/** Merge stored settings over env/first-workspace defaults. */ +export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings { + const workspaces = listWorkspaces(cfg.harnessDir); + return { + workspace: stored.workspace ?? (workspaces[0]?.name), + provider: stored.provider ?? cfg.defaults.provider, + model: stored.model ?? cfg.defaults.model, + thinking: stored.thinking ?? cfg.defaults.thinking, + }; +} + +export function settingsRoutes( + app: FastifyInstance, + deps: { cfg: AppConfig; listModels: ListModelsFn }, +): void { + app.get("/settings", async () => { + return effectiveSettings(deps.cfg, loadSettings(deps.cfg)); + }); + + app.put("/settings", async (req, reply) => { + const b = (req.body ?? {}) as Settings; + if (b.model) { + let available: { id: string }[] = []; + try { + available = await deps.listModels(); + } catch { + available = []; + } + // Only validate when Pi gave us a non-empty list; otherwise allow (degraded). + if (available.length > 0 && !available.some((m) => m.id === b.model)) { + return reply.code(400).send({ error: `Unknown model: ${b.model}` }); + } + } + const next: Settings = { + workspace: b.workspace, + provider: b.provider, + model: b.model, + thinking: b.thinking, + }; + saveSettings(deps.cfg, next); + return effectiveSettings(deps.cfg, next); + }); +} +``` + +- [ ] **Step 5: Wire app.ts** + +In `backend/src/app.ts`, add imports near the existing route imports: + +```ts +import { settingsRoutes } from "./routes/settings.js"; +import { createPiModelLister } from "./pi/list-models.js"; +import { loadSettings, type Settings } from "./settings/settings-store.js"; +import { effectiveSettings } from "./routes/settings.js"; +``` + +Extend `BuildAppDeps`: + +```ts +export interface BuildAppDeps { + thtRunner?: ThtRunner; + spawnFn?: () => any; + listModels?: ListModelsFn; + getSettings?: () => Settings; +} +``` + +Inside `buildApp`, after `const hub = new SseHub();` and before the route registrations, resolve the model lister and the settings accessor: + +```ts + const listModels = deps?.listModels ?? createPiModelLister(config); + const getSettings = deps?.getSettings ?? (() => effectiveSettings(config, loadSettings(config))); +``` + +Change the route registrations to: + +```ts + sessionRoutes(app, { mgr, tht: tht as ThtRunner, hub, getSettings }); + sqlRoutes(app, { tht: tht as ThtRunner }); + metaRoutes(app, { harnessDir: config.harnessDir, listModels }); + settingsRoutes(app, { cfg: config, listModels }); +``` + +(Leave the existing `const tht = ...`, `const mgr = ...` lines unchanged. `getSettings` is consumed by `sessionRoutes` in Task 4 — adding it to the deps object now is harmless.) + +- [ ] **Step 6: Update the existing `/models` expectations** + +In `backend/test/routes-sql-meta.test.ts`, the three `/models` tests assert the old string-array shape. Replace the first of them (the injected-stub test, lines ~123–133) with the object shape: + +```ts +test("GET /models returns {models:[...]} from injected listModels stub", async () => { + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: {} as any, + listModels: async () => [ + { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, + ], + }); + + const res = await app.inject({ method: "GET", url: "/models" }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + models: [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], + }); +}); +``` + +The two fallback tests (`listModels throws` and `no listModels injected`) already expect `{ models: [] }` and stay valid. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cd backend && npx vitest run test/routes-settings.test.ts test/routes-sql-meta.test.ts` +Expected: PASS (all settings tests + the updated meta tests). + +- [ ] **Step 8: Commit** + +```bash +git add backend/src/routes/meta.ts backend/src/routes/settings.ts backend/src/app.ts backend/test/routes-settings.test.ts backend/test/routes-sql-meta.test.ts +git commit -m "feat(backend): /settings GET+PUT, /models PiModel shape, app wiring" +``` + +--- + +## Task 4: `POST /sessions` applies settings (not body params) + +**Files:** +- Modify: `backend/src/routes/sessions.ts` +- Modify: `backend/test/routes-sessions.test.ts` + +**Interfaces:** +- Consumes: `getSettings: () => Settings` (added to deps in Task 3 app wiring). +- Produces: `sessionRoutes(app, d: { mgr; tht; hub; getSettings })`. `POST /sessions` body is `{ question: string; name?: string }`. Workspace/provider/model/thinking come from `d.getSettings()`. + +- [ ] **Step 1: Update the failing test** + +Replace the first test in `backend/test/routes-sessions.test.ts` (the "POST /sessions crea e avvia…" test, lines 10–25) with a version where settings — not the body — supply the workspace: + +```ts +test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => { + let sessionNewArg: any; + let spawnArg: any; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { + sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; }, + sessionList: async () => [{ id: "s1" }], + } as any, + getSettings: () => ({ workspace: "w", provider: "zai", model: "glm-5.2", thinking: "high" }), + spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any, + }); + const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); + expect(created.json()).toEqual({ id: "s1" }); + expect(sessionNewArg.workspace).toBe("w"); + expect(sessionNewArg.provider).toBe("zai"); + expect(sessionNewArg.model).toBe("glm-5.2"); + expect(sessionNewArg.thinking).toBe("high"); + expect(sessionNewArg.question).toBe("q"); + const list = await app.inject({ method: "GET", url: "/sessions" }); + expect(list.json()).toEqual([{ id: "s1" }]); +}); +``` + +In the second test ("POST /sessions/:id/response inoltra al bridge"), add a `getSettings` stub to the deps so the route has its settings source, and drop `workspace` from the payload: + +```ts +test("POST /sessions/:id/response inoltra al bridge (no error)", async () => { + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { sessionNew: async () => ({ id: "s1" }) } as any, + getSettings: () => ({ workspace: "w" }), + spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any, + }); + await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/response", + payload: { ui_response: { id: "u1", choices: ["a"] } } }); + expect(res.statusCode).toBe(204); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run test/routes-sessions.test.ts` +Expected: FAIL — route still reads `b.workspace` (undefined now), so `sessionNewArg.workspace` is `undefined`, assertion fails. + +- [ ] **Step 3: Update the route** + +In `backend/src/routes/sessions.ts`, update the signature and the `POST /sessions` handler. Add `Settings` import at the top: + +```ts +import type { Settings } from "../settings/settings-store.js"; +``` + +Change the function signature: + +```ts +export function sessionRoutes( + app: FastifyInstance, + d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings }, +) { +``` + +Replace the `POST /sessions` handler body (lines 8–18) with: + +```ts + app.post("/sessions", async (req, reply) => { + const b = req.body as { question: string; name?: string }; + const s = d.getSettings(); + // Settings (global) supply workspace/provider/model/thinking. The new-question + // form sends only the question text. `workspace` selects the tht `-c `. + const { id } = await d.tht.sessionNew({ + question: b.question, + name: b.name, + workspace: s.workspace, + provider: s.provider, + model: s.model, + thinking: s.thinking, + }); + const rt = await d.mgr.spawnFor(id, { + provider: s.provider, + model: s.model, + thinking: s.thinking, + author: getUser(req).id, + }); + rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e)); + return { id }; + }); +``` + +(`reply` stays in the signature to match the existing style; it is unused here as before.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run test/routes-sessions.test.ts` +Expected: PASS (both tests). + +- [ ] **Step 5: Run the whole backend suite (no regressions)** + +Run: `cd backend && npx vitest run` +Expected: PASS for all files. If `routes-sessions` e2e or other tests reference `workspace` in the POST body, none should remain after this task — confirm green. + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/routes/sessions.ts backend/test/routes-sessions.test.ts +git commit -m "feat(backend): POST /sessions applies global settings, body is question-only" +``` + +--- + +## Task 5: Frontend API layer (models type, settings api, simplified createSession) + +**Files:** +- Modify: `frontend/src/api/models.ts` +- Create: `frontend/src/api/settings.ts` +- Modify: `frontend/src/api/sessions.ts` +- Modify: `frontend/src/api/sessions.test.ts` + +**Interfaces:** +- Produces: + - `models.ts`: `export interface PiModel { provider: string; id: string; name: string; reasoning: boolean }`; `listModels(): Promise<{ models: PiModel[] }>`. + - `settings.ts`: `export interface Settings { workspace?: string; provider?: string; model?: string; thinking?: string }`; `getSettings(): Promise`; `putSettings(s: Settings): Promise`. + - `sessions.ts`: `createSession(i: { question: string; name?: string }): Promise<{ id: string }>`. + +- [ ] **Step 1: Update the failing test** + +Replace `frontend/src/api/sessions.test.ts` with: + +```ts +import { http, HttpResponse } from "msw"; +import { server } from "../test/msw"; +import { createSession, listSessions } from "./sessions"; + +test("createSession POSTs only {question} and returns the id", async () => { + let body: unknown = null; + server.use( + http.post("http://localhost:8787/sessions", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "s1" }); + }), + ); + expect(await createSession({ question: "q" })).toEqual({ id: "s1" }); + expect(body).toEqual({ question: "q" }); +}); + +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(); + expect(rows[0].id).toBe("s1"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/api/sessions.test.ts` +Expected: FAIL — `createSession` currently requires `workspace`; `body` includes `workspace`. + +- [ ] **Step 3: Simplify createSession** + +In `frontend/src/api/sessions.ts`, replace the `createSession` definition with: + +```ts +export const createSession = (i: { question: string; name?: string }) => + apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) }); +``` + +(Leave the rest of the file unchanged.) + +- [ ] **Step 4: Type models.ts** + +Replace `frontend/src/api/models.ts` with: + +```ts +import { apiFetch } from "./client"; + +export interface PiModel { + provider: string; + id: string; + name: string; + reasoning: boolean; +} + +export const listModels = () => apiFetch<{ models: PiModel[] }>("/models"); +``` + +- [ ] **Step 5: Create settings.ts** + +Create `frontend/src/api/settings.ts`: + +```ts +import { apiFetch } from "./client"; + +export interface Settings { + workspace?: string; + provider?: string; + model?: string; + thinking?: string; +} + +export const getSettings = () => apiFetch("/settings"); + +export const putSettings = (s: Settings) => + apiFetch("/settings", { method: "PUT", body: JSON.stringify(s) }); +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd frontend && npx vitest run src/api/sessions.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/api/models.ts frontend/src/api/settings.ts frontend/src/api/sessions.ts frontend/src/api/sessions.test.ts +git commit -m "feat(frontend): settings api, typed PiModel, question-only createSession" +``` + +--- + +## Task 6: Shrink NewSessionDialog to question-only + +**Files:** +- Modify: `frontend/src/shell/NewSessionDialog.tsx` +- Modify: `frontend/src/shell/NewSessionDialog.test.tsx` + +**Interfaces:** +- Consumes: `createSession({ question })` (Task 5). +- Produces: `NewSessionDialog` renders only a question textarea + Crea/Annulla; on submit calls `createSession({ question })` and `onCreated(id)`. + +- [ ] **Step 1: Replace the test** + +Replace `frontend/src/shell/NewSessionDialog.test.tsx` with: + +```tsx +// frontend/src/shell/NewSessionDialog.test.tsx +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { server } from "../test/msw"; +import { NewSessionDialog } from "./NewSessionDialog"; + +function renderDialog() { + const onCreated = vi.fn(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return { onCreated }; +} + +test("the form has only a question field (no workspace/model/provider/thinking)", async () => { + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /nuova/i })); + expect(await screen.findByLabelText(/domanda/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/workspace/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/modello/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/provider/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument(); +}); + +test("submitting posts only { question } and calls onCreated", async () => { + let body: unknown = null; + server.use( + http.post("http://localhost:8787/sessions", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "s1" }); + }), + ); + const { onCreated } = renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /nuova/i })); + await userEvent.type(await screen.findByLabelText(/domanda/i), "Quante vendite nel 2025?"); + await userEvent.click(screen.getByRole("button", { name: /^crea$/i })); + + await waitFor(() => expect(body).toEqual({ question: "Quante vendite nel 2025?" })); + await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1")); +}); + +test("empty question shows a validation error and does not submit", async () => { + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /nuova/i })); + await userEvent.click(screen.getByRole("button", { name: /^crea$/i })); + expect(await screen.findByRole("alert")).toHaveTextContent(/vuota/i); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/shell/NewSessionDialog.test.tsx` +Expected: FAIL — current dialog still renders workspace/model/provider/thinking and posts `{ workspace, question }`. + +- [ ] **Step 3: Rewrite the component** + +Replace `frontend/src/shell/NewSessionDialog.tsx` with: + +```tsx +// frontend/src/shell/NewSessionDialog.tsx +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; +import { Button } from "../components/ui/button"; +import { createSession } from "../api/sessions"; + +interface Props { + onCreated: (id: string) => void; +} + +export function NewSessionDialog({ onCreated }: Props) { + const [open, setOpen] = useState(false); + const [question, setQuestion] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!question.trim()) { + setError("La domanda non può essere vuota."); + return; + } + setBusy(true); + try { + const { id } = await createSession({ question: question.trim() }); + setOpen(false); + setQuestion(""); + onCreated(id); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore nella creazione della sessione."); + } finally { + setBusy(false); + } + } + + return ( + + + + + Nuova sessione + +
+
+ +