# Pi-Enabled Model Selector 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:** Restore a usable model selector that exposes exactly GLM 5.2, DeepSeek V4 Flash, and local Qwen3.6 from Pi's effective `enabledModels` configuration. **Architecture:** Add a focused Pi-settings reader that resolves global/project precedence and returns exact composite model IDs. The ephemeral Pi lister will run with a scrubbed, provider-neutral environment, intersect Pi's available catalog with those IDs, and keep the existing API shape; session spawning will explicitly treat `local-qwen` as local. **Tech Stack:** Node.js 22, TypeScript, Fastify, Pi RPC, Vitest, React 18, Testing Library/MSW, Docker Compose. ## Global Constraints - The selector exposes exactly `zai/glm-5.2`, `deepseek/deepseek-v4-flash`, and `local-qwen/qwen3.6-35b-a3b`. - `zai/glm-5v-turbo` remains hidden. - Pi `enabledModels` is the single source of truth; do not add an application allowlist. - Only exact `provider/model` entries are accepted; do not expand wildcard, fuzzy, model-only, or thinking-suffixed patterns. - Missing or malformed model scope fails closed to an empty model list and emits only sanitized warnings. - Do not expose credential values or `auth.json` contents in code, tests, logs, or command output. - Preserve hosted-provider credential isolation and reject unknown providers. - UI strings remain English. - Backend gates are `npx vitest run` and `npx tsc --noEmit -p .`. - Frontend gates are `npx vitest run` and `npx tsc -b`. - Completion requires rebuilding and recreating the impacted Docker container, not merely building source assets. --- ## File map - Create `backend/src/pi/enabled-models.ts`: load and validate effective Pi `enabledModels`. - Create `backend/test/enabled-models.test.ts`: precedence, validation, and fail-closed unit tests. - Modify `backend/src/pi/list-models.ts`: provider-neutral Pi spawn, enabled-model intersection, warning callback. - Modify `backend/test/list-models.test.ts`: regression, ordering, filtering, caching, and scrubbed-environment tests. - Modify `backend/src/routes/settings.ts`: validate the composite `provider/model` pair. - Modify `backend/src/routes/meta.ts`: sanitized warning when the lister fails. - Modify `backend/src/app.ts`: wire model-scope warnings into the Fastify logger. - Modify `backend/test/routes-settings.test.ts`: duplicate-ID/different-provider validation regression. - Modify `backend/src/pi/provider-credentials.ts`: classify `local-qwen` as local. - Modify `backend/test/provider-credentials.test.ts`: direct custom-local credential-policy test. - Modify `backend/test/pi-process-manager.test.ts`: session-spawn coverage for `local-qwen`. - Modify `frontend/src/shell/AppShell.new-session.test.tsx`: render and select the three-model response. - Modify `/home/chirone/thothii-data/pi-config/agent/settings.json`: remove live GLM-5V from `enabledModels`. - Modify `PROJECT_STATE.md`: record the regression fix, verification counts, deployment time, and image/container digest. --- ### Task 1: Effective Pi `enabledModels` reader **Files:** - Create: `backend/src/pi/enabled-models.ts` - Test: `backend/test/enabled-models.test.ts` **Interfaces:** - Consumes: `harnessDir`, optional `agentDir`, and an injectable `(path: string) => string` reader. - Produces: `loadPiEnabledModels(opts): PiEnabledModelsResult`, where the result is `{ ids: string[]; warnings: string[]; source?: string }`. - [ ] **Step 1: Write the failing settings-reader tests** ```ts import { expect, test } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadPiEnabledModels } from "../src/pi/enabled-models.js"; function fixture(globalValue: unknown, projectValue?: unknown) { const root = mkdtempSync(join(tmpdir(), "tht-enabled-models-")); const agentDir = join(root, "agent"); const harnessDir = join(root, "harness"); mkdirSync(agentDir, { recursive: true }); mkdirSync(join(harnessDir, ".pi"), { recursive: true }); writeFileSync(join(agentDir, "settings.json"), JSON.stringify(globalValue)); if (projectValue !== undefined) { writeFileSync(join(harnessDir, ".pi", "settings.json"), JSON.stringify(projectValue)); } return { root, agentDir, harnessDir }; } test("loads exact global enabledModels in configured order", () => { const f = fixture({ enabledModels: [ "zai/glm-5.2", "deepseek/deepseek-v4-flash", "local-qwen/qwen3.6-35b-a3b", ] }); try { expect(loadPiEnabledModels(f)).toMatchObject({ ids: [ "zai/glm-5.2", "deepseek/deepseek-v4-flash", "local-qwen/qwen3.6-35b-a3b", ], warnings: [], }); } finally { rmSync(f.root, { recursive: true, force: true }); } }); test("project enabledModels overrides global enabledModels", () => { const f = fixture( { enabledModels: ["zai/glm-5.2", "zai/glm-5v-turbo"] }, { enabledModels: ["local-qwen/qwen3.6-35b-a3b"] }, ); try { expect(loadPiEnabledModels(f).ids).toEqual(["local-qwen/qwen3.6-35b-a3b"]); } finally { rmSync(f.root, { recursive: true, force: true }); } }); test("project settings without enabledModels fall back to global settings", () => { const f = fixture({ enabledModels: ["zai/glm-5.2"] }, { theme: "thothii-mono" }); try { expect(loadPiEnabledModels(f).ids).toEqual(["zai/glm-5.2"]); } finally { rmSync(f.root, { recursive: true, force: true }); } }); test("invalid entries are ignored, deduplicated, and reported without their content", () => { const f = fixture({ enabledModels: [ "zai/glm-5.2", "zai/glm-5.2", "glm-5.2", "zai/*", "zai/glm-5.2:high", 7, ] }); try { const result = loadPiEnabledModels(f); expect(result.ids).toEqual(["zai/glm-5.2"]); expect(result.warnings).toHaveLength(4); expect(result.warnings.join(" ")).not.toContain("glm-5.2:high"); } finally { rmSync(f.root, { recursive: true, force: true }); } }); test.each([ ["missing field", {}], ["empty field", { enabledModels: [] }], ["wrong type", { enabledModels: "zai/glm-5.2" }], ])("%s fails closed", (_name, settings) => { const f = fixture(settings); try { const result = loadPiEnabledModels(f); expect(result.ids).toEqual([]); expect(result.warnings.length).toBeGreaterThan(0); } finally { rmSync(f.root, { recursive: true, force: true }); } }); test("malformed project settings fail closed instead of exposing global models", () => { const f = fixture({ enabledModels: ["zai/glm-5.2"] }); writeFileSync(join(f.harnessDir, ".pi", "settings.json"), "{"); try { const result = loadPiEnabledModels(f); expect(result.ids).toEqual([]); expect(result.warnings).toHaveLength(1); } finally { rmSync(f.root, { recursive: true, force: true }); } }); test("missing global settings fail closed", () => { const f = fixture({ enabledModels: ["zai/glm-5.2"] }); rmSync(join(f.agentDir, "settings.json")); try { const result = loadPiEnabledModels(f); expect(result.ids).toEqual([]); expect(result.warnings).toHaveLength(1); } finally { rmSync(f.root, { recursive: true, force: true }); } }); ``` - [ ] **Step 2: Run the focused test and verify the missing module failure** Run: `cd backend && npx vitest run test/enabled-models.test.ts` Expected: FAIL because `../src/pi/enabled-models.js` does not exist. - [ ] **Step 3: Implement the effective settings reader** ```ts import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; export interface PiEnabledModelsResult { ids: string[]; warnings: string[]; source?: string; } interface LoadOptions { harnessDir: string; agentDir?: string; read?: (path: string) => string; } type Settings = Record; const INVALID = Symbol("invalid-settings"); function readSettings( path: string, optional: boolean, read: (path: string) => string, warnings: string[], ): Settings | undefined | typeof INVALID { try { const value = JSON.parse(read(path)); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(); return value as Settings; } catch (error) { if (optional && (error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined; warnings.push(`Pi settings are unreadable or invalid: ${path}`); return INVALID; } } function isExactCompositeId(value: unknown): value is string { if (typeof value !== "string" || /[\s*?:]/.test(value)) return false; const parts = value.split("/"); return parts.length === 2 && parts.every((part) => part.length > 0); } export function loadPiEnabledModels(opts: LoadOptions): PiEnabledModelsResult { const warnings: string[] = []; const read = opts.read ?? ((path: string) => readFileSync(path, "utf8")); const agentDir = opts.agentDir ?? join(homedir(), ".pi", "agent"); const globalPath = join(agentDir, "settings.json"); const projectPath = join(opts.harnessDir, ".pi", "settings.json"); const globalSettings = readSettings(globalPath, false, read, warnings); const projectSettings = readSettings(projectPath, true, read, warnings); if (globalSettings === INVALID || projectSettings === INVALID) return { ids: [], warnings }; const projectDefinesScope = projectSettings ? Object.prototype.hasOwnProperty.call(projectSettings, "enabledModels") : false; const settings = projectDefinesScope ? projectSettings : globalSettings; const source = projectDefinesScope ? projectPath : globalPath; const raw = settings?.enabledModels; if (!Array.isArray(raw) || raw.length === 0) { warnings.push(`Pi enabledModels is missing or empty: ${source}`); return { ids: [], warnings, source }; } const ids: string[] = []; const seen = new Set(); raw.forEach((value, index) => { if (!isExactCompositeId(value)) { warnings.push(`Ignoring non-exact enabledModels entry at index ${index}: ${source}`); return; } if (!seen.has(value)) { seen.add(value); ids.push(value); } }); if (ids.length === 0) warnings.push(`Pi enabledModels contains no exact model IDs: ${source}`); return { ids, warnings, source }; } ``` - [ ] **Step 4: Run the reader tests and type-check** Run: `cd backend && npx vitest run test/enabled-models.test.ts && npx tsc --noEmit -p .` Expected: all focused tests PASS and TypeScript exits 0. - [ ] **Step 5: Commit the reader** ```bash git add backend/src/pi/enabled-models.ts backend/test/enabled-models.test.ts git commit -m "feat(backend): read Pi enabled model scope" ``` --- ### Task 2: Provider-neutral Pi listing and exact scope filtering **Files:** - Modify: `backend/src/pi/list-models.ts` - Modify: `backend/test/list-models.test.ts` **Interfaces:** - Consumes: `loadPiEnabledModels({ harnessDir })` from Task 1 and Pi RPC `get_available_models`. - Produces: `createPiModelLister(cfg, opts): () => Promise`; new options are `loadEnabledModels?: () => PiEnabledModelsResult` and `warn?: (message: string) => void`. - [ ] **Step 1: Replace provider-specific lister expectations with failing regression tests** Add this helper near the top of `backend/test/list-models.test.ts`. In the first mapping test pass both `zai/glm-5.2` and `anthropic/claude-opus-4-8`; in the cache test pass `zai/glm-5.2`; in environment-only tests whose fake catalog is empty pass `test/unavailable` so Pi still spawns. This keeps every existing lister test independent of the developer's home directory: ```ts function enabled(...ids: string[]) { return () => ({ ids, warnings: [], source: "/test/settings.json" }); } ``` Remove the obsolete lister-only tests that expect enumeration to inject the selected provider key or reject compound providers. Session credential isolation remains covered by `provider-credentials.test.ts` and `pi-process-manager.test.ts`. Add these regression tests: ```ts test("model listing does not require PI_PROVIDER or read the generic credential", async () => { const script = scriptWith([ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, ]); const calls: any[][] = []; try { const lister = createPiModelLister(loadConfig({ PI_BIN: "/usr/local/bin/pi", THT_MODEL_API_KEY_FILE: "/missing-and-must-not-be-read", }), { loadEnabledModels: enabled("zai/glm-5.2"), spawnFn: (...args: any[]) => { calls.push(args); return spawn("node", [FAKE, script]) as any; }, }); await expect(lister()).resolves.toHaveLength(1); expect(calls[0][2].env).not.toHaveProperty("ZAI_API_KEY"); expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE"); } finally { rmSync(path.dirname(script), { recursive: true, force: true }); } }); test("returns only enabled available models in enabledModels order", async () => { const script = scriptWith([ { provider: "zai", id: "glm-5v-turbo", name: "GLM-5V-Turbo", reasoning: true }, { provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 Local", reasoning: false }, { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true }, ]); try { const lister = createPiModelLister(loadConfig({}), { loadEnabledModels: enabled( "zai/glm-5.2", "deepseek/deepseek-v4-flash", "local-qwen/qwen3.6-35b-a3b", ), spawnFn: () => spawn("node", [FAKE, script]) as any, }); expect((await lister()).map((m) => `${m.provider}/${m.id}`)).toEqual([ "zai/glm-5.2", "deepseek/deepseek-v4-flash", "local-qwen/qwen3.6-35b-a3b", ]); } finally { rmSync(path.dirname(script), { recursive: true, force: true }); } }); test("empty enabled model scope fails closed without spawning Pi", async () => { let spawns = 0; const warnings: string[] = []; const lister = createPiModelLister(loadConfig({}), { loadEnabledModels: () => ({ ids: [], warnings: ["scope invalid"] }), warn: (message) => warnings.push(message), spawnFn: () => { spawns += 1; throw new Error("must not spawn"); }, }); await expect(lister()).resolves.toEqual([]); expect(spawns).toBe(0); expect(warnings).toEqual(["scope invalid"]); }); test("warns and returns empty when enabled identifiers are unavailable", async () => { const script = scriptWith([ { provider: "zai", id: "glm-5v-turbo", name: "GLM-5V-Turbo", reasoning: true }, ]); const warnings: string[] = []; try { const lister = createPiModelLister(loadConfig({}), { loadEnabledModels: enabled("zai/glm-5.2"), warn: (message) => warnings.push(message), spawnFn: () => spawn("node", [FAKE, script]) as any, }); await expect(lister()).resolves.toEqual([]); expect(warnings).toEqual(["No Pi-enabled models are currently available"]); } finally { rmSync(path.dirname(script), { recursive: true, force: true }); } }); ``` - [ ] **Step 2: Run the lister tests and verify the old implementation fails** Run: `cd backend && npx vitest run test/list-models.test.ts` Expected: FAIL because the lister still requires the configured provider credential, does not accept `loadEnabledModels`, and does not filter/order the response. - [ ] **Step 3: Implement provider-neutral enumeration and filtering** In `backend/src/pi/list-models.ts`, remove the `secretValue` import, import `loadPiEnabledModels` and its result type, extend `Opts`, and use the following core flow: ```ts import { loadPiEnabledModels, type PiEnabledModelsResult, } from "./enabled-models.js"; interface Opts { spawnFn?: ( command: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }, ) => ChildProcessWithoutNullStreams; ttlMs?: number; nowMs?: () => number; loadEnabledModels?: () => PiEnabledModelsResult; warn?: (message: string) => void; } // Inside listModels(), before spawning Pi: const enabled = (opts.loadEnabledModels ?? (() => loadPiEnabledModels({ harnessDir: cfg.harnessDir })))(); for (const warning of enabled.warnings) opts.warn?.(warning); if (enabled.ids.length === 0) { cache = { at: now(), models: [] }; return []; } const env = buildPiChildEnv({}); delete env.THT_DATA_ROOT; if (cfg.dataRoot !== undefined) env.THT_DATA_ROOT = cfg.dataRoot; // After mapping the RPC response to `available: PiModel[]`: const byCompositeId = new Map( available.map((model) => [`${model.provider}/${model.id}`, model]), ); const models = enabled.ids.flatMap((id) => { const model = byCompositeId.get(id); return model ? [model] : []; }); if (models.length === 0) opts.warn?.("No Pi-enabled models are currently available"); cache = { at: now(), models }; return models; ``` Keep the existing `finally { child.kill(); }`, eight-second timeout, response mapping, TTL behavior, cwd, and `THT_DATA_ROOT` handling unchanged. - [ ] **Step 4: Run focused and neighboring backend tests** Run: `cd backend && npx vitest run test/enabled-models.test.ts test/list-models.test.ts test/provider-credentials.test.ts` Expected: all tests PASS. - [ ] **Step 5: Commit the lister regression fix** ```bash git add backend/src/pi/list-models.ts backend/test/list-models.test.ts git commit -m "fix(backend): scope Pi model listing to enabled models" ``` --- ### Task 3: Composite settings validation and sanitized observability **Files:** - Modify: `backend/src/routes/settings.ts` - Modify: `backend/src/routes/meta.ts` - Modify: `backend/src/app.ts` - Modify: `backend/test/routes-settings.test.ts` - Modify: `backend/test/routes-sql-meta.test.ts` **Interfaces:** - Consumes: filtered `ListModelsFn` results containing `{ provider, id }`. - Produces: `PUT /settings` validation by composite ID and warning-only Fastify logs for model-list failures. - [ ] **Step 1: Write the failing composite-validation test** ```ts test("PUT /settings validates provider and model as one composite identifier", async () => { const { app, dir } = appWithTmpSettings({}, { listModels: async () => [ { provider: "provider-a", id: "shared-id", name: "A", reasoning: false }, ], }); try { const wrongProvider = await app.inject({ method: "PUT", url: "/settings", payload: { workspace: "psd", provider: "provider-b", model: "shared-id", thinking: "low", }, }); expect(wrongProvider.statusCode).toBe(400); const exactPair = await app.inject({ method: "PUT", url: "/settings", payload: { workspace: "psd", provider: "provider-a", model: "shared-id", thinking: "low", }, }); expect(exactPair.statusCode).toBe(200); } finally { rmSync(dir, { recursive: true, force: true }); } }); ``` In `routes-sql-meta.test.ts`, spy on the application logger and assert the graceful fallback logs only an error type, not the thrown message: ```ts test("GET /models logs a sanitized warning when listing fails", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any, listModels: async () => { throw new Error("credential-value-must-not-appear"); }, }); const warn = vi.spyOn(app.log, "warn"); const res = await app.inject({ method: "GET", url: "/models" }); expect(res.json()).toEqual({ models: [] }); expect(JSON.stringify(warn.mock.calls)).not.toContain("credential-value-must-not-appear"); expect(warn).toHaveBeenCalled(); }); ``` Add `vi` to that test file's Vitest import. - [ ] **Step 2: Run route tests and verify failures** Run: `cd backend && npx vitest run test/routes-settings.test.ts test/routes-sql-meta.test.ts` Expected: FAIL because validation currently compares only `id` and the failure path does not log. - [ ] **Step 3: Implement composite validation and warnings** In `settings.ts`, replace the available model type and predicate: ```ts let available: { provider: string; id: string }[] = []; // existing try/catch remains if (available.length > 0 && !available.some( (candidate) => candidate.provider === b.provider && candidate.id === b.model, )) { return reply.code(400).send({ error: `Unknown model: ${b.provider ?? "unknown"}/${b.model}`, }); } ``` In `meta.ts`, accept the request and log only a sanitized type: ```ts app.get("/models", async (req) => { const fn = deps.listModels ?? (async () => []); try { return { models: await fn() }; } catch (error) { app.log.warn({ component: "pi-model-list", errorType: error instanceof Error ? error.name : typeof error, }, "Pi model listing failed"); return { models: [] as PiModel[] }; } }); ``` In `app.ts`, enable warning-level explicit logs without request logging and route configuration warnings through the same logger: ```ts const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true }); const listModels = deps?.listModels ?? createPiModelLister(config, { warn: (detail) => app.log.warn( { component: "pi-model-list", detail }, "Pi enabled-model configuration warning", ), }); ``` - [ ] **Step 4: Run route tests and the backend type checker** Run: `cd backend && npx vitest run test/routes-settings.test.ts test/routes-sql-meta.test.ts && npx tsc --noEmit -p .` Expected: all route tests PASS and TypeScript exits 0. - [ ] **Step 5: Commit API validation and logging** ```bash git add backend/src/routes/settings.ts backend/src/routes/meta.ts backend/src/app.ts backend/test/routes-settings.test.ts backend/test/routes-sql-meta.test.ts git commit -m "fix(backend): validate configured model provider pair" ``` --- ### Task 4: `local-qwen` session credential policy **Files:** - Modify: `backend/src/pi/provider-credentials.ts` - Modify: `backend/test/provider-credentials.test.ts` - Modify: `backend/test/pi-process-manager.test.ts` **Interfaces:** - Consumes: selected provider `local-qwen` from application settings. - Produces: a scrubbed Pi child environment with no generic hosted-provider credential requirement. - [ ] **Step 1: Write failing direct and session-spawn tests** Add to `provider-credentials.test.ts`: ```ts test("local-qwen is an explicit local provider and needs no generic key", () => { const env = buildPiChildEnv({ ambient: { PI_PROVIDER_API_KEY: "must-not-leak", OPENAI_API_KEY: "must-not-leak", THT_MODEL_API_KEY_FILE: "/must/not/leak", }, provider: "local-qwen", }); expect(env).not.toHaveProperty("PI_PROVIDER_API_KEY"); expect(env).not.toHaveProperty("OPENAI_API_KEY"); expect(env).not.toHaveProperty("THT_MODEL_API_KEY_FILE"); }); ``` Change the existing local session test in `pi-process-manager.test.ts` to run for both providers: ```ts test.each(["ollama", "local-qwen"])( "local provider %s spawns without a model key and scrubs ambient credentials", async (provider) => { vi.stubEnv("PI_PROVIDER_API_KEY", "ambient-secret"); vi.stubEnv("THT_MODEL_API_KEY_FILE", "/ambient/secret-path"); vi.stubEnv("OPENAI_API_KEY", "unselected-provider-secret"); const calls: any[][] = []; const child = recordingChild(); child.stderr.resume = () => {}; const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), { spawnFn: (...args: any[]) => { calls.push(args); return child as any; }, }); try { await mgr.spawnFor(`local-session-${provider}`, { provider }); expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY"); expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE"); expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY"); } finally { mgr.teardown(`local-session-${provider}`); vi.unstubAllEnvs(); } }, ); ``` - [ ] **Step 2: Run the focused tests and verify `local-qwen` is rejected** Run: `cd backend && npx vitest run test/provider-credentials.test.ts test/pi-process-manager.test.ts` Expected: the `local-qwen` cases FAIL with `model provider credential is unavailable`. - [ ] **Step 3: Add only `local-qwen` to the explicit local-provider set** ```ts const LOCAL_PROVIDERS = new Set([ "ollama", "lmstudio", "local", "aritmolab", "local-qwen", "faux", ]); ``` Do not relax the unknown-provider or compound-provider branches. - [ ] **Step 4: Run credential and process-manager tests** Run: `cd backend && npx vitest run test/provider-credentials.test.ts test/pi-process-manager.test.ts` Expected: all tests PASS, including the existing unknown/compound-provider failures. - [ ] **Step 5: Commit the provider policy change** ```bash git add backend/src/pi/provider-credentials.ts backend/test/provider-credentials.test.ts backend/test/pi-process-manager.test.ts git commit -m "fix(backend): allow configured local Qwen provider" ``` --- ### Task 5: Frontend selector regression test **Files:** - Modify: `frontend/src/shell/AppShell.new-session.test.tsx` **Interfaces:** - Consumes: unchanged `GET /models` response with three `PiModel` objects. - Produces: test proof that the selector displays all three and persists Qwen's provider/model pair. - [ ] **Step 1: Add the three-model selector test** Add `within` to the Testing Library import, then add: ```tsx test("model selector shows the three Pi-enabled models and persists the selected provider", async () => { let saved: unknown; server.use( http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", })), http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true }, { provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B A3B Local", reasoning: false }, ] })), http.put("http://localhost:8787/settings", async ({ request }) => { saved = await request.json(); return HttpResponse.json(saved); }), ); renderShell(); const select = await screen.findByRole("combobox", { name: "Model" }); expect(within(select).getAllByRole("option").map((option) => option.textContent)).toEqual([ "GLM-5.2", "DeepSeek V4 Flash", "Qwen3.6 35B A3B Local", ]); await userEvent.selectOptions(select, "qwen3.6-35b-a3b"); await waitFor(() => expect(saved).toMatchObject({ provider: "local-qwen", model: "qwen3.6-35b-a3b", })); }); ``` - [ ] **Step 2: Run the focused frontend test** Run: `cd frontend && npx vitest run src/shell/AppShell.new-session.test.tsx` Expected: PASS with the existing component. - [ ] **Step 3: Run frontend type checking** Run: `cd frontend && npx tsc -b` Expected: TypeScript exits 0. - [ ] **Step 4: Commit the regression coverage** ```bash git add frontend/src/shell/AppShell.new-session.test.tsx git commit -m "test(frontend): cover configured model selector" ``` --- ### Task 6: Full verification, live Pi scope, Docker recreation, and state record **Files:** - Modify: `/home/chirone/thothii-data/pi-config/agent/settings.json` - Modify: `PROJECT_STATE.md` **Interfaces:** - Consumes: the completed backend/frontend commits and mounted Pi profile. - Produces: deployed healthy `core` container whose live `/models` response contains exactly the three approved models. - [ ] **Step 1: Run the complete source gates from a clean task branch** ```bash cd backend && npx vitest run && npx tsc --noEmit -p . cd ../frontend && npx vitest run && npx tsc -b cd .. && git diff --check && git status --short ``` Expected: both complete test suites PASS, both type checks exit 0, `git diff --check` prints nothing, and status contains only intentional task files plus the pre-existing untracked `.vite/` directory in the main workspace. - [ ] **Step 2: Update the mounted Pi scope using the approved exact list** Apply this JSON change to `/home/chirone/thothii-data/pi-config/agent/settings.json` while preserving all other keys and formatting: ```diff "enabledModels": [ "deepseek/deepseek-v4-flash", "zai/glm-5.2", - "local-qwen/qwen3.6-35b-a3b", - "zai/glm-5v-turbo" + "local-qwen/qwen3.6-35b-a3b" ] ``` Validate without displaying credentials: ```bash docker compose exec -T core node -e ' const s=require("/home/thoth/.pi/agent/settings.json"); console.log(JSON.stringify({enabledModels:s.enabledModels})); ' ``` Expected: the printed array contains exactly DeepSeek Flash, GLM 5.2, and local Qwen; GLM-5V is absent. - [ ] **Step 3: Build and recreate only the impacted `core` service** ```bash docker compose build core docker compose up -d --no-deps --force-recreate --wait --wait-timeout 60 core docker compose ps core ``` Expected: build exits 0 and `core` reaches `running (healthy)`. - [ ] **Step 4: Verify the live API returns the exact composite IDs** ```bash docker compose exec -T core node --input-type=module - <<'NODE' const response = await fetch("http://127.0.0.1:8787/models"); const body = await response.json(); const ids = body.models.map((m) => `${m.provider}/${m.id}`); const expected = [ "deepseek/deepseek-v4-flash", "zai/glm-5.2", "local-qwen/qwen3.6-35b-a3b", ]; if (response.status !== 200 || JSON.stringify(ids) !== JSON.stringify(expected)) { throw new Error(`unexpected model list: ${JSON.stringify(ids)}`); } console.log(JSON.stringify({ status: response.status, ids })); NODE ``` Expected: status 200 and exactly the three IDs in Pi `enabledModels` order. - [ ] **Step 5: Smoke-test settings validation and restore the original setting** First exercise the same process-manager path used by a session, stopping before any prompt is sent: ```bash docker compose exec -T core node --input-type=module - <<'NODE' import { loadConfig } from "/app/backend/dist/config.js"; import { PiProcessManager } from "/app/backend/dist/pi/pi-process-manager.js"; const manager = new PiProcessManager(loadConfig(process.env)); for (const [provider, model] of [ ["deepseek", "deepseek-v4-flash"], ["local-qwen", "qwen3.6-35b-a3b"], ]) { const id = `model-smoke-${provider}`; const runtime = manager.createFor(id, { provider }); try { await manager.configure(runtime, { provider, model, thinking: "low" }); console.log(JSON.stringify({ provider, model, configured: true })); } finally { manager.teardown(id); } } NODE ``` Expected: both models print `configured: true`; no session prompt or DWH operation is started. Then verify API validation while restoring the user's setting: ```bash docker compose exec -T core node --input-type=module - <<'NODE' const base = "http://127.0.0.1:8787"; const original = await (await fetch(`${base}/settings`)).json(); async function put(provider, model) { return fetch(`${base}/settings`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...original, provider, model }), }); } try { for (const [provider, model] of [ ["deepseek", "deepseek-v4-flash"], ["local-qwen", "qwen3.6-35b-a3b"], ]) { const response = await put(provider, model); if (response.status !== 200) throw new Error(`${provider}/${model}: ${response.status}`); } const hidden = await put("zai", "glm-5v-turbo"); if (hidden.status !== 400) throw new Error(`hidden model accepted: ${hidden.status}`); console.log(JSON.stringify({ deepseek: 200, localQwen: 200, hiddenGlm5v: 400 })); } finally { const restored = await fetch(`${base}/settings`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(original), }); if (restored.status !== 200) throw new Error(`settings restore failed: ${restored.status}`); } NODE ``` Expected: DeepSeek and Qwen return 200, GLM-5V returns 400, and the original settings are restored even if a smoke assertion fails. - [ ] **Step 6: Inspect health, sanitized logs, and runtime identity** ```bash docker compose ps core docker compose logs --since=10m core docker image inspect thothii-core:local --format '{{.Id}} {{.Created}}' docker inspect thothii-core-1 --format '{{.Image}} {{.State.Health.Status}} {{.State.StartedAt}}' ``` Expected: health is `healthy`; logs contain no credential value, uncaught model-list error, or unexpected Pi crash; the running container image ID equals the rebuilt image. - [ ] **Step 7: Update and commit `PROJECT_STATE.md`** Record the verified backend/frontend test totals, type-check success, the three live model IDs, removal of GLM-5V, deployment timestamp, rebuilt image ID, running container image ID, and health result. Preserve the file's current snapshot format. ```bash git add PROJECT_STATE.md git commit -m "docs: record model selector deployment" git status --short ``` Expected: the commit succeeds and the task branch is clean apart from the known untracked `.vite/` directory if it is visible in that workspace. --- ## Final review checklist - [ ] Every approved model appears once and in Pi configuration order. - [ ] GLM-5V and other authenticated Pi models are absent from `/models`. - [ ] A missing `PI_PROVIDER` does not prevent model enumeration. - [ ] Enumeration does not inherit or inject generic/provider credentials. - [ ] DeepSeek and Qwen settings updates succeed; the hidden model is rejected. - [ ] `local-qwen` spawns without a hosted-provider key. - [ ] Unknown and compound providers still fail closed. - [ ] No credential values appear in logs or test output. - [ ] Backend and frontend complete gates pass. - [ ] The rebuilt `core` container is healthy and runs the new image.