29 KiB
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— allowcreateLlmClient({ model })overrides. - Modify:
src/modules/agent/messages.ts— accept optional chat requestmodel. - 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 nullableassistantModeluser column. - Create:
drizzle/0025_assistant_model.sql— addassistant_modelcolumn. - Modify:
drizzle/meta/_journal.json— add the migration entry for0025_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 formodel. - 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:
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:
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:
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<string>();
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<LlmModelsResult> {
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<string, string> = {};
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<string, LlmModelOption>();
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:
pnpm exec tsx --test tests/unit/llm-models.test.ts
Expected: pass.
- Step 5: Commit the helper
Run:
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:
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:
ALTER TABLE "users" ADD COLUMN "assistant_model" text;
Add this entry to the end of the entries array in drizzle/meta/_journal.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:
export type AssistantPreferences = {
enabled: boolean;
name: string;
systemPrompt: string | null;
model: string | null;
};
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);
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:
import { isValidLlmModelId, listLlmModels } from "@/lib/llm/models";
Add the server action:
export async function setAssistantModel(model: string | null): Promise<void> {
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:
pnpm typecheck
Expected: pass with users.assistantModel recognized from src/modules/_core/schema.ts.
- Step 6: Commit persistence
Run:
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:
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:
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:
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:
import { isValidLlmModelId } from "@/lib/llm/models";
Add a reusable schema:
export const clientChatModelSchema = z
.string()
.trim()
.refine((value) => isValidLlmModelId(value), "Invalid assistant model");
Update clientChatInputSchema:
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:
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:
export async function runAgentChat(options: {
messages: ClientChatMessage[];
request: Request;
systemPrompt?: string;
model?: string;
llm?: LlmClient;
executeTool?: ToolExecutor;
onProgress?: AgentProgressHandler;
}): Promise<AgentChatResult> {
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:
import { listLlmModels, resolveAssistantModel } from "@/lib/llm/models";
Before the stream branch, add:
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:
const result = await runAgentChat({
messages: parsed.data.messages,
request,
systemPrompt,
model: modelResolution.model,
onProgress: send,
});
const result = await runAgentChat({
messages: parsed.data.messages,
request,
systemPrompt,
model: modelResolution.model,
});
- Step 8: Run targeted tests
Run:
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:
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:
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:
pnpm typecheck
Expected: pass.
- Step 3: Commit the route
Run:
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:
let assistantModel: string | null = null;
Select it:
assistantModel: users.assistantModel,
Assign it when the row exists:
assistantModel = row.assistantModel?.trim() || null;
Pass it into AssistantBubble:
<AssistantBubble
configured={isLlmConfigured()}
userId={session.user.id}
assistantName={assistantName}
assistantModel={assistantModel}
/>
- Step 2: Thread the prop through
AssistantBubble
In src/modules/agent/components/assistant-bubble.tsx, update props:
type Props = {
configured: boolean;
userId: string;
assistantName: string;
assistantModel: string | null;
};
Update the component signature:
export function AssistantBubble({ configured, userId, assistantName, assistantModel }: Props) {
Pass it to AssistantPanel:
<AssistantPanel
key={userId}
configured={configured}
userId={userId}
assistantName={assistantName}
assistantModel={assistantModel}
/>
- 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:
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { setAssistantModel } from "@/app/settings/assistant-actions";
Add types:
type LlmModelOption = {
id: string;
label: string;
};
type ModelsResponse = {
models: LlmModelOption[];
selectedModel: string;
fallbackModel: string;
degraded: boolean;
};
Extend props:
type Props = {
configured: boolean;
userId: string;
assistantName: string;
assistantModel: string | null;
};
Update the function signature:
export function AssistantPanel({ configured, userId, assistantName, assistantModel }: Props) {
Add state after the pending/error state:
const [modelOptions, setModelOptions] = useState<LlmModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState(assistantModel ?? "");
const [fallbackModel, setFallbackModel] = useState("");
const [modelsDegraded, setModelsDegraded] = useState(false);
const [modelsLoading, setModelsLoading] = useState(true);
Add the model loading effect:
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:
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:
import { useEffect, useRef, useState, useTransition } from "react";
Then add:
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:
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="muted text-[12px] leading-relaxed">
{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."}
</p>
{modelsDegraded ? (
<p className="muted mt-1 text-[11px]">Model discovery unavailable; using fallback.</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{modelOptions.length > 0 ? (
<Select
items={modelOptions.map((model) => ({ value: model.id, label: model.label }))}
value={selectedModel}
onValueChange={changeModel}
disabled={modelsLoading || savingModel || isPending}
>
<SelectTrigger size="sm" className="max-w-36" aria-label="Assistant model">
<SelectValue>{selectedModel || "Model"}</SelectValue>
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{modelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : null}
{messages.length > 0 ? (
<button
type="button"
onClick={clearChat}
disabled={isPending}
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Clear
</button>
) : null}
</div>
</div>
- Step 5: Include the selected model in chat requests
In sendMessage, update the JSON body:
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:
await expect(page.getByRole("combobox", { name: "Assistant model" })).toBeVisible();
- Step 7: Run typecheck and targeted E2E
Run:
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:
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:
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:
pnpm typecheck
Expected: pass.
- Step 3: Run lint
Run:
pnpm lint
Expected: exit 0.
- Step 4: Run assistant E2E
Run:
pnpm test:e2e -- tests/e2e/assistant.spec.ts
Expected: assistant opt-in and chat smoke pass.
- Step 5: Check git status
Run:
git status --short
Expected: clean worktree.