# Assistant 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:** Add a provider-discovered assistant model selector that saves each user's selected model and uses it for chat completions. **Architecture:** Keep model discovery server-side in a focused LLM helper, expose it through `/api/agent/models`, and persist the selected non-default model on the `users` row. The chat route resolves the effective model from the posted model, saved preference, and `LLM_MODEL` fallback before creating the OpenAI-compatible client. **Tech Stack:** Next.js 15 App Router, TypeScript, Drizzle, PostgreSQL, shadcn/ui Base UI Select, Tailwind v4, Node test runner via `tsx --test`, Playwright. --- ## File Map - Create: `src/lib/llm/models.ts` — fetch, normalize, validate, and resolve LLM model choices. - Create: `src/app/api/agent/models/route.ts` — authenticated model-discovery endpoint for the chat UI. - Modify: `src/lib/llm/config.ts` — centralize fallback model shape through existing config. - Modify: `src/lib/llm/index.ts` — allow `createLlmClient({ model })` overrides. - Modify: `src/modules/agent/messages.ts` — accept optional chat request `model`. - Modify: `src/modules/agent/server/run.ts` — pass the effective model to the LLM client. - Modify: `src/app/api/agent/chat/route.ts` — validate and resolve requested/saved model choices. - Modify: `src/modules/_core/schema.ts` — add nullable `assistantModel` user column. - Create: `drizzle/0025_assistant_model.sql` — add `assistant_model` column. - Modify: `drizzle/meta/_journal.json` — add the migration entry for `0025_assistant_model`. - Modify: `src/lib/assistant-preference.ts` — load the saved assistant model. - Modify: `src/app/settings/assistant-actions.ts` — add server action to save the selected assistant model. - Modify: `src/app/layout.tsx` — pass initial saved model into the assistant bubble. - Modify: `src/modules/agent/components/assistant-bubble.tsx` — pass initial saved model into the panel. - Modify: `src/modules/agent/components/assistant-panel.tsx` — render the selector, load models, save changes, and include selected model on chat requests. - Create: `tests/unit/llm-models.test.ts` — pure model discovery and validation tests. - Modify: `tests/unit/agent-chat.test.ts` — LLM override and chat runner coverage. - Modify: `tests/unit/agent-messages.test.ts` — request schema coverage for `model`. - Modify: `tests/e2e/assistant.spec.ts` — selector render smoke. --- ### Task 1: LLM Model Discovery Helper **Files:** - Create: `tests/unit/llm-models.test.ts` - Create: `src/lib/llm/models.ts` - [ ] **Step 1: Write the failing model helper tests** Create `tests/unit/llm-models.test.ts`: ```ts import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { isValidLlmModelId, listLlmModels, normalizeLlmModelsPayload, resolveAssistantModel, } from "../../src/lib/llm/models"; import type { LlmConfig } from "../../src/lib/llm/config"; const openAiConfig: LlmConfig = { provider: "openai", baseUrl: "https://llm.example.test/v1", apiKey: "secret", model: "llama3.2", }; describe("normalizeLlmModelsPayload", () => { it("normalizes OpenAI-compatible data arrays", () => { const models = normalizeLlmModelsPayload({ data: [{ id: "qwen2.5-coder" }, { id: "llama3.2" }, { id: "qwen2.5-coder" }], }); assert.deepEqual(models, [ { id: "llama3.2", label: "llama3.2" }, { id: "qwen2.5-coder", label: "qwen2.5-coder" }, ]); }); it("ignores invalid or empty model rows", () => { const models = normalizeLlmModelsPayload({ data: [{ id: "" }, { id: " " }, { id: "bad model" }, { object: "model" }], }); assert.deepEqual(models, []); }); }); describe("isValidLlmModelId", () => { it("accepts common provider model IDs", () => { assert.equal(isValidLlmModelId("llama3.2"), true); assert.equal(isValidLlmModelId("qwen2.5-coder:latest"), true); assert.equal(isValidLlmModelId("hf.co/ginnoir/model-v1"), true); }); it("rejects empty, whitespace, and overlong model IDs", () => { assert.equal(isValidLlmModelId(""), false); assert.equal(isValidLlmModelId("bad model"), false); assert.equal(isValidLlmModelId("x".repeat(129)), false); }); }); describe("listLlmModels", () => { it("fetches provider models with API key auth and includes the fallback model", async () => { const requests: Request[] = []; const result = await listLlmModels({ config: openAiConfig, fetchImpl: async (input, init) => { requests.push(new Request(input, init)); return Response.json({ data: [{ id: "qwen2.5-coder" }] }); }, }); assert.equal(requests[0]?.url, "https://llm.example.test/v1/models"); assert.equal(requests[0]?.headers.get("authorization"), "Bearer secret"); assert.deepEqual(result.models, [ { id: "llama3.2", label: "llama3.2" }, { id: "qwen2.5-coder", label: "qwen2.5-coder" }, ]); assert.equal(result.fallbackModel, "llama3.2"); assert.equal(result.degraded, false); }); it("falls back to LLM_MODEL when provider discovery fails", async () => { const result = await listLlmModels({ config: openAiConfig, fetchImpl: async () => new Response("nope", { status: 500 }), }); assert.deepEqual(result.models, [{ id: "llama3.2", label: "llama3.2" }]); assert.equal(result.fallbackModel, "llama3.2"); assert.equal(result.degraded, true); }); it("uses fallback only for mock provider config", async () => { const result = await listLlmModels({ config: { provider: "mock", baseUrl: null, apiKey: null, model: "llama3.2" }, fetchImpl: async () => { throw new Error("fetch should not run for mock config"); }, }); assert.deepEqual(result.models, [{ id: "llama3.2", label: "llama3.2" }]); assert.equal(result.degraded, false); }); }); describe("resolveAssistantModel", () => { it("uses a valid requested model before saved and fallback values", () => { const resolved = resolveAssistantModel({ requestedModel: "qwen2.5-coder", savedModel: "llama3.2", fallbackModel: "llama3.2", models: [ { id: "llama3.2", label: "llama3.2" }, { id: "qwen2.5-coder", label: "qwen2.5-coder" }, ], }); assert.deepEqual(resolved, { ok: true, model: "qwen2.5-coder" }); }); it("rejects invalid requested models", () => { const resolved = resolveAssistantModel({ requestedModel: "missing", savedModel: null, fallbackModel: "llama3.2", models: [{ id: "llama3.2", label: "llama3.2" }], }); assert.deepEqual(resolved, { ok: false, model: "llama3.2", error: "Invalid assistant model", }); }); it("silently falls back when a saved model is gone", () => { const resolved = resolveAssistantModel({ requestedModel: null, savedModel: "old-model", fallbackModel: "llama3.2", models: [{ id: "llama3.2", label: "llama3.2" }], }); assert.deepEqual(resolved, { ok: true, model: "llama3.2" }); }); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run: ```powershell pnpm exec tsx --test tests/unit/llm-models.test.ts ``` Expected: fail with a module resolution error for `src/lib/llm/models.ts`. - [ ] **Step 3: Implement the model helper** Create `src/lib/llm/models.ts`: ```ts import { getLlmConfig, type LlmConfig } from "./config"; export type LlmModelOption = { id: string; label: string; }; export type LlmModelsResult = { models: LlmModelOption[]; fallbackModel: string; degraded: boolean; }; export type AssistantModelResolution = | { ok: true; model: string } | { ok: false; model: string; error: string }; const MODEL_ID_PATTERN = /^[A-Za-z0-9._:/-]+$/; const MAX_MODEL_ID_LENGTH = 128; export function isValidLlmModelId(value: string): boolean { const trimmed = value.trim(); return ( trimmed.length > 0 && trimmed.length <= MAX_MODEL_ID_LENGTH && trimmed === value && MODEL_ID_PATTERN.test(trimmed) ); } export function normalizeLlmModelsPayload(payload: unknown): LlmModelOption[] { const data = typeof payload === "object" && payload !== null && "data" in payload ? (payload as { data?: unknown }).data : null; if (!Array.isArray(data)) return []; const ids = new Set(); for (const row of data) { if (typeof row !== "object" || row === null || !("id" in row)) continue; const id = (row as { id?: unknown }).id; if (typeof id !== "string") continue; const trimmed = id.trim(); if (!isValidLlmModelId(trimmed)) continue; ids.add(trimmed); } return [...ids].sort((a, b) => a.localeCompare(b)).map((id) => ({ id, label: id })); } export async function listLlmModels(options?: { config?: LlmConfig; fetchImpl?: typeof fetch; }): Promise { const config = options?.config ?? getLlmConfig(); const fetchImpl = options?.fetchImpl ?? fetch; const fallbackModel = config.model; const fallbackOption = { id: fallbackModel, label: fallbackModel }; if (config.provider === "mock" || !config.baseUrl) { return { models: [fallbackOption], fallbackModel, degraded: false }; } try { const headers: Record = {}; if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; const response = await fetchImpl(`${config.baseUrl.replace(/\/$/, "")}/models`, { method: "GET", headers, }); if (!response.ok) { return { models: [fallbackOption], fallbackModel, degraded: true }; } const models = normalizeLlmModelsPayload(await response.json()); const merged = new Map(); merged.set(fallbackModel, fallbackOption); for (const model of models) merged.set(model.id, model); return { models: [...merged.values()].sort((a, b) => a.id.localeCompare(b.id)), fallbackModel, degraded: models.length === 0, }; } catch { return { models: [fallbackOption], fallbackModel, degraded: true }; } } export function resolveAssistantModel(options: { requestedModel: string | null | undefined; savedModel: string | null | undefined; fallbackModel: string; models: LlmModelOption[]; }): AssistantModelResolution { const available = new Set(options.models.map((model) => model.id)); const fallback = available.has(options.fallbackModel) ? options.fallbackModel : (options.models[0]?.id ?? options.fallbackModel); if (options.requestedModel) { if (!available.has(options.requestedModel)) { return { ok: false, model: fallback, error: "Invalid assistant model" }; } return { ok: true, model: options.requestedModel }; } if (options.savedModel && available.has(options.savedModel)) { return { ok: true, model: options.savedModel }; } return { ok: true, model: fallback }; } ``` - [ ] **Step 4: Run the model helper tests** Run: ```powershell pnpm exec tsx --test tests/unit/llm-models.test.ts ``` Expected: pass. - [ ] **Step 5: Commit the helper** Run: ```powershell git add src/lib/llm/models.ts tests/unit/llm-models.test.ts git commit -m "feat(agent): add llm model discovery helper" ``` --- ### Task 2: Persist Assistant Model Preference **Files:** - Modify: `src/modules/_core/schema.ts` - Create: `drizzle/0025_assistant_model.sql` - Modify: `drizzle/meta/_journal.json` - Modify: `src/lib/assistant-preference.ts` - Modify: `src/app/settings/assistant-actions.ts` - [ ] **Step 1: Update the Drizzle user schema** In `src/modules/_core/schema.ts`, add the nullable text column next to the other assistant fields: ```ts assistantEnabled: boolean("assistant_enabled").notNull().default(false), assistantName: text("assistant_name").notNull().default("Assistant"), assistantSystemPrompt: text("assistant_system_prompt"), assistantModel: text("assistant_model"), defaultEventReminderOffsets: jsonb("default_event_reminder_offsets") ``` - [ ] **Step 2: Add the migration SQL** Create `drizzle/0025_assistant_model.sql`: ```sql ALTER TABLE "users" ADD COLUMN "assistant_model" text; ``` Add this entry to the end of the `entries` array in `drizzle/meta/_journal.json`: ```json { "idx": 25, "version": "7", "when": 1783560000000, "tag": "0025_assistant_model", "breakpoints": true } ``` - [ ] **Step 3: Load the saved model preference** Update `src/lib/assistant-preference.ts` so the type, select, and return object include `model`: ```ts export type AssistantPreferences = { enabled: boolean; name: string; systemPrompt: string | null; model: string | null; }; ``` ```ts const [row] = await db .select({ assistantEnabled: users.assistantEnabled, assistantName: users.assistantName, assistantSystemPrompt: users.assistantSystemPrompt, assistantModel: users.assistantModel, }) .from(users) .where(eq(users.id, userId)) .limit(1); ``` ```ts return { enabled: row?.assistantEnabled ?? false, name: row?.assistantName?.trim() || DEFAULT_ASSISTANT_NAME, systemPrompt: row?.assistantSystemPrompt ?? null, model: row?.assistantModel?.trim() || null, }; ``` - [ ] **Step 4: Add a server action for saving the model** In `src/app/settings/assistant-actions.ts`, import the helper: ```ts import { isValidLlmModelId, listLlmModels } from "@/lib/llm/models"; ``` Add the server action: ```ts export async function setAssistantModel(model: string | null): Promise { const { user } = await getCurrentSession(); const normalized = model?.trim() || null; if (normalized !== null && !isValidLlmModelId(normalized)) { throw new Error("Invalid assistant model"); } const available = await listLlmModels(); const requested = normalized === available.fallbackModel ? null : normalized; if (requested !== null && !available.models.some((option) => option.id === requested)) { throw new Error("Invalid assistant model"); } await db.update(users).set({ assistantModel: requested }).where(eq(users.id, user.id)); revalidateAssistantSurfaces(); } ``` - [ ] **Step 5: Run typecheck** Run: ```powershell pnpm typecheck ``` Expected: pass with `users.assistantModel` recognized from `src/modules/_core/schema.ts`. - [ ] **Step 6: Commit persistence** Run: ```powershell git add src/modules/_core/schema.ts drizzle/0025_assistant_model.sql drizzle/meta/_journal.json src/lib/assistant-preference.ts src/app/settings/assistant-actions.ts git commit -m "feat(agent): persist assistant model preference" ``` --- ### Task 3: Use the Effective Model in Chat **Files:** - Modify: `tests/unit/agent-messages.test.ts` - Modify: `tests/unit/agent-chat.test.ts` - Modify: `src/modules/agent/messages.ts` - Modify: `src/lib/llm/index.ts` - Modify: `src/modules/agent/server/run.ts` - Modify: `src/app/api/agent/chat/route.ts` - [ ] **Step 1: Add request schema tests** Append to `tests/unit/agent-messages.test.ts`: ```ts it("accepts an optional model ID", () => { const parsed = clientChatInputSchema.safeParse({ model: "qwen2.5-coder", messages: [{ role: "user", content: "hello" }], }); assert.equal(parsed.success, true); }); it("rejects invalid model IDs", () => { const parsed = clientChatInputSchema.safeParse({ model: "bad model", messages: [{ role: "user", content: "hello" }], }); assert.equal(parsed.success, false); }); ``` - [ ] **Step 2: Add an LLM client override test** Append to `tests/unit/agent-chat.test.ts`: ```ts it("passes a model override to the OpenAI-compatible client", async () => { const originalBaseUrl = process.env.LLM_BASE_URL; const originalModel = process.env.LLM_MODEL; const originalProvider = process.env.LLM_PROVIDER; const originalFetch = globalThis.fetch; let requestBody: unknown = null; process.env.LLM_BASE_URL = "https://llm.example.test/v1"; process.env.LLM_MODEL = "llama3.2"; delete process.env.LLM_PROVIDER; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { requestBody = JSON.parse(String(init?.body)); return Response.json({ choices: [{ message: { role: "assistant", content: "done" }, finish_reason: "stop" }], }); }) as typeof fetch; const { createLlmClient } = await import("../../src/lib/llm/index"); const client = createLlmClient({ model: "qwen2.5-coder" }); await client.chatCompletion({ messages: [{ role: "user", content: "hello" }] }); assert.equal((requestBody as { model?: string }).model, "qwen2.5-coder"); globalThis.fetch = originalFetch; if (originalBaseUrl === undefined) delete process.env.LLM_BASE_URL; else process.env.LLM_BASE_URL = originalBaseUrl; if (originalModel === undefined) delete process.env.LLM_MODEL; else process.env.LLM_MODEL = originalModel; if (originalProvider === undefined) delete process.env.LLM_PROVIDER; else process.env.LLM_PROVIDER = originalProvider; }); ``` - [ ] **Step 3: Run the tests to verify they fail** Run: ```powershell pnpm exec tsx --test tests/unit/agent-messages.test.ts tests/unit/agent-chat.test.ts ``` Expected: `agent-messages` fails because `model` is not accepted, and `agent-chat` fails because `createLlmClient` does not accept the override object yet. - [ ] **Step 4: Extend the chat input schema** In `src/modules/agent/messages.ts`, import the model validator: ```ts import { isValidLlmModelId } from "@/lib/llm/models"; ``` Add a reusable schema: ```ts export const clientChatModelSchema = z .string() .trim() .refine((value) => isValidLlmModelId(value), "Invalid assistant model"); ``` Update `clientChatInputSchema`: ```ts export const clientChatInputSchema = z.object({ stream: z.boolean().optional(), model: clientChatModelSchema.optional(), messages: z.array(clientChatMessageSchema).min(1).max(40), }); ``` - [ ] **Step 5: Add LLM client model override support** Replace `createLlmClient` in `src/lib/llm/index.ts` with: ```ts export function createLlmClient(options?: { model?: string; override?: LlmClient }): LlmClient { if (options?.override) return options.override; const config = getLlmConfig(); if (config.provider === "mock" || !config.baseUrl) { return createMockLlmClient(); } return createOpenAiCompatibleClient({ baseUrl: config.baseUrl, apiKey: config.apiKey, model: options?.model ?? config.model, }); } ``` - [ ] **Step 6: Pass model through the agent runner** In `src/modules/agent/server/run.ts`, extend options and client creation: ```ts export async function runAgentChat(options: { messages: ClientChatMessage[]; request: Request; systemPrompt?: string; model?: string; llm?: LlmClient; executeTool?: ToolExecutor; onProgress?: AgentProgressHandler; }): Promise { const llm = options.llm ?? createLlmClient({ model: options.model }); ``` - [ ] **Step 7: Resolve and validate model in the chat route** In `src/app/api/agent/chat/route.ts`, import: ```ts import { listLlmModels, resolveAssistantModel } from "@/lib/llm/models"; ``` Before the stream branch, add: ```ts const modelList = await listLlmModels(); const modelResolution = resolveAssistantModel({ requestedModel: parsed.data.model, savedModel: assistant.model, fallbackModel: modelList.fallbackModel, models: modelList.models, }); if (!modelResolution.ok) { return apiError(modelResolution.error, 400); } ``` Pass `model: modelResolution.model` in both `runAgentChat` calls: ```ts const result = await runAgentChat({ messages: parsed.data.messages, request, systemPrompt, model: modelResolution.model, onProgress: send, }); ``` ```ts const result = await runAgentChat({ messages: parsed.data.messages, request, systemPrompt, model: modelResolution.model, }); ``` - [ ] **Step 8: Run targeted tests** Run: ```powershell pnpm exec tsx --test tests/unit/agent-messages.test.ts tests/unit/agent-chat.test.ts tests/unit/llm-models.test.ts ``` Expected: pass. - [ ] **Step 9: Commit chat model wiring** Run: ```powershell git add src/modules/agent/messages.ts src/lib/llm/index.ts src/modules/agent/server/run.ts src/app/api/agent/chat/route.ts tests/unit/agent-messages.test.ts tests/unit/agent-chat.test.ts git commit -m "feat(agent): route chat through selected model" ``` --- ### Task 4: Add Model Discovery API **Files:** - Create: `src/app/api/agent/models/route.ts` - [ ] **Step 1: Implement the route** Create `src/app/api/agent/models/route.ts`: ```ts import { apiError, apiJson } from "@/lib/api-handler"; import { resolveApiAuth } from "@/lib/api-auth"; import { getAssistantPreferences } from "@/lib/assistant-preference"; import { listLlmModels, resolveAssistantModel } from "@/lib/llm/models"; export async function GET(request: Request) { const auth = await resolveApiAuth(request); if (!auth?.userId) { return apiError("Unauthorized", 401); } const assistant = await getAssistantPreferences(auth.userId); if (!assistant.enabled) { return apiError("Assistant not enabled", 403); } const modelList = await listLlmModels(); const resolved = resolveAssistantModel({ requestedModel: null, savedModel: assistant.model, fallbackModel: modelList.fallbackModel, models: modelList.models, }); return apiJson({ models: modelList.models, selectedModel: resolved.model, fallbackModel: modelList.fallbackModel, degraded: modelList.degraded, }); } ``` - [ ] **Step 2: Run typecheck** Run: ```powershell pnpm typecheck ``` Expected: pass. - [ ] **Step 3: Commit the route** Run: ```powershell git add src/app/api/agent/models/route.ts git commit -m "feat(agent): expose available assistant models" ``` --- ### Task 5: Add the Chat Panel Selector **Files:** - Modify: `src/app/layout.tsx` - Modify: `src/modules/agent/components/assistant-bubble.tsx` - Modify: `src/modules/agent/components/assistant-panel.tsx` - Modify: `tests/e2e/assistant.spec.ts` - [ ] **Step 1: Pass saved model from layout to the panel** In `src/app/layout.tsx`, add local state: ```ts let assistantModel: string | null = null; ``` Select it: ```ts assistantModel: users.assistantModel, ``` Assign it when the row exists: ```ts assistantModel = row.assistantModel?.trim() || null; ``` Pass it into `AssistantBubble`: ```tsx ``` - [ ] **Step 2: Thread the prop through `AssistantBubble`** In `src/modules/agent/components/assistant-bubble.tsx`, update props: ```ts type Props = { configured: boolean; userId: string; assistantName: string; assistantModel: string | null; }; ``` Update the component signature: ```ts export function AssistantBubble({ configured, userId, assistantName, assistantModel }: Props) { ``` Pass it to `AssistantPanel`: ```tsx ``` - [ ] **Step 3: Add selector state and fetch helpers to `AssistantPanel`** In `src/modules/agent/components/assistant-panel.tsx`, import the Select pieces and server action: ```ts import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { setAssistantModel } from "@/app/settings/assistant-actions"; ``` Add types: ```ts type LlmModelOption = { id: string; label: string; }; type ModelsResponse = { models: LlmModelOption[]; selectedModel: string; fallbackModel: string; degraded: boolean; }; ``` Extend props: ```ts type Props = { configured: boolean; userId: string; assistantName: string; assistantModel: string | null; }; ``` Update the function signature: ```ts export function AssistantPanel({ configured, userId, assistantName, assistantModel }: Props) { ``` Add state after the pending/error state: ```ts const [modelOptions, setModelOptions] = useState([]); const [selectedModel, setSelectedModel] = useState(assistantModel ?? ""); const [fallbackModel, setFallbackModel] = useState(""); const [modelsDegraded, setModelsDegraded] = useState(false); const [modelsLoading, setModelsLoading] = useState(true); ``` Add the model loading effect: ```ts useEffect(() => { let cancelled = false; async function loadModels() { setModelsLoading(true); try { const response = await fetch("/api/agent/models"); if (!response.ok) throw new Error("Model discovery unavailable"); const payload = (await response.json()) as ModelsResponse; if (cancelled) return; setModelOptions(payload.models); setSelectedModel(payload.selectedModel); setFallbackModel(payload.fallbackModel); setModelsDegraded(payload.degraded); } catch { if (cancelled) return; setModelsDegraded(true); } finally { if (!cancelled) setModelsLoading(false); } } void loadModels(); return () => { cancelled = true; }; }, []); ``` Add the save handler: ```ts function changeModel(nextModel: string) { setSelectedModel(nextModel); setError(null); startTransition(async () => { try { await setAssistantModel(nextModel === fallbackModel ? null : nextModel); } catch (err) { setError(err instanceof Error ? err.message : "Could not save assistant model"); } }); } ``` Update the React import: ```ts import { useEffect, useRef, useState, useTransition } from "react"; ``` Then add: ```ts const [savingModel, startTransition] = useTransition(); ``` - [ ] **Step 4: Render the selector above the message list** Replace the current top row in `AssistantPanel` with a two-column responsive row: ```tsx

{configured ? "Type, talk, or send a photo — I can update lists, calendar, notes, and more." : "Mock provider active — set LLM_BASE_URL for your homelab model."}

{modelsDegraded ? (

Model discovery unavailable; using fallback.

) : null}
{modelOptions.length > 0 ? ( ) : null} {messages.length > 0 ? ( ) : null}
``` - [ ] **Step 5: Include the selected model in chat requests** In `sendMessage`, update the JSON body: ```ts body: JSON.stringify({ messages: nextMessages.map(toClientChatMessage), model: selectedModel || undefined, stream: true, }), ``` - [ ] **Step 6: Update the E2E smoke** In `tests/e2e/assistant.spec.ts`, after the dialog assertion, add: ```ts await expect(page.getByRole("combobox", { name: "Assistant model" })).toBeVisible(); ``` - [ ] **Step 7: Run typecheck and targeted E2E** Run: ```powershell pnpm typecheck pnpm test:e2e -- tests/e2e/assistant.spec.ts ``` Expected: typecheck passes and both assistant E2E tests pass. Playwright config owns dev server startup; do not leave a manual server running. - [ ] **Step 8: Commit the UI** Run: ```powershell git add src/app/layout.tsx src/modules/agent/components/assistant-bubble.tsx src/modules/agent/components/assistant-panel.tsx tests/e2e/assistant.spec.ts git commit -m "feat(agent): add assistant model selector" ``` --- ### Task 6: Final Verification and Cleanup **Files:** - No planned edits. - [ ] **Step 1: Run all targeted unit tests** Run: ```powershell pnpm exec tsx --test tests/unit/llm-models.test.ts tests/unit/agent-messages.test.ts tests/unit/agent-chat.test.ts ``` Expected: all tests pass. - [ ] **Step 2: Run typecheck** Run: ```powershell pnpm typecheck ``` Expected: pass. - [ ] **Step 3: Run lint** Run: ```powershell pnpm lint ``` Expected: exit 0. - [ ] **Step 4: Run assistant E2E** Run: ```powershell pnpm test:e2e -- tests/e2e/assistant.spec.ts ``` Expected: assistant opt-in and chat smoke pass. - [ ] **Step 5: Check git status** Run: ```powershell git status --short ``` Expected: clean worktree.