Files
ThothII/docs/superpowers/plans/2026-07-14-pi-enabled-model-selector.md
T

33 KiB

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

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
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<string, unknown>;
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<string>();
  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
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<PiModel[]>; 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:

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:

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:

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
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

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:

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:

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:

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:

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
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:

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:

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
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
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:

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
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

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:

   "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:

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
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
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:

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:

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
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.

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.