feat: sync OpenRouter models on dashboard startup and add mission interview module

- Add openrouterModelSync setting to eagerly fetch OpenRouter model catalog at
  dashboard startup so the model picker shows all available models
- Add toggle in Settings → Models to disable the sync
- Add mission-interview session management module for AI-guided mission specs
- Include tests for the sync opt-out behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-04 00:39:17 -07:00
parent 38a095cc25
commit e46d357a6a
6 changed files with 586 additions and 1 deletions

View File

@@ -16,6 +16,7 @@ function makeMockStore() {
maxWorktrees: 2,
autoMerge: false,
pollIntervalMs: 60_000,
openrouterModelSync: true,
}),
listTasks: vi.fn().mockResolvedValue([]),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
@@ -104,7 +105,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
// ── Mock @mariozechner/pi-coding-agent ──────────────────────────────
const mockAuthStorage = { getAuth: vi.fn(), setAuth: vi.fn() };
const mockAuthStorage = { getAuth: vi.fn(), setAuth: vi.fn(), getApiKey: vi.fn() };
const mockModelRegistry = {
getModels: vi.fn().mockResolvedValue([]),
registerProvider: vi.fn(),
@@ -252,4 +253,22 @@ describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
expect(mockModelRegistry.refresh).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("skips OpenRouter model sync when openrouterModelSync is false", async () => {
const { TaskStore } = await import("@fusion/core");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
...makeMockStore(),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
pollIntervalMs: 60_000,
openrouterModelSync: false,
}),
}));
await runDashboard(0, {});
expect(mockAuthStorage.getApiKey).not.toHaveBeenCalled();
});
});

View File

@@ -504,6 +504,49 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
// Eagerly sync OpenRouter models — the pi-openrouter-realtime extension
// only registers providers on session_start (TUI-only event), so kick off
// a fetch here so the dashboard model list is populated. Respects the
// openrouterModelSync setting (defaults to true).
(async () => {
try {
const settings = await store.getSettings();
if (settings.openrouterModelSync === false) return;
const hasOrAuth = await authStorage.getApiKey("openrouter");
const headers: Record<string, string> = {};
if (hasOrAuth) headers["Authorization"] = `Bearer ${hasOrAuth}`;
const res = await fetch("https://openrouter.ai/api/v1/models", { headers });
if (!res.ok) return;
const json = await res.json() as { data?: Array<{ id: string; name: string; context_length?: number; top_provider?: { max_completion_tokens?: number }; pricing?: Record<string, string>; architecture?: { modality?: string; input_modalities?: string[] } }> };
const orModels = (json.data || []).map((m: any) => {
const id = (m.id || "").toLowerCase();
const name = (m.name || "").toLowerCase();
const reasoning = id.includes(":thinking") || id.includes("-r1") || id.includes("/r1") || id.includes("o1-") || id.includes("o3-") || id.includes("o4-") || id.includes("reasoner") || name.includes("thinking") || name.includes("reasoner");
const hasVision = m.architecture?.input_modalities?.includes("image") ?? m.architecture?.modality?.includes("multimodal") ?? false;
function parseCost(v?: string) { const n = parseFloat(v || "0"); return isNaN(n) ? 0 : n * 1_000_000; }
return {
id: m.id,
name: m.name || m.id,
reasoning,
input: (hasVision ? ["text", "image"] : ["text"]) as ("text" | "image")[],
cost: { input: parseCost(m.pricing?.prompt), output: parseCost(m.pricing?.completion), cacheRead: parseCost(m.pricing?.input_cache_read), cacheWrite: parseCost(m.pricing?.input_cache_write) },
contextWindow: m.context_length || 128000,
maxTokens: m.top_provider?.max_completion_tokens || 16384,
};
});
modelRegistry.registerProvider("openrouter", {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "OPENROUTER_API_KEY",
api: "openai-completions",
models: orModels,
});
console.log(`[openrouter] Synced ${orModels.length} models from OpenRouter API`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.log(`[openrouter] Failed to sync models: ${message}`);
}
})();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`[extensions] Failed to discover extensions: ${message}`);

View File

@@ -955,6 +955,25 @@ export function SettingsModal({
</>
) : null}
{/* --- OpenRouter Model Sync --- */}
<h4 className="settings-section-heading" style={{ marginTop: "1.5rem" }}>OpenRouter Models</h4>
<div className="form-group">
<label htmlFor="openrouterModelSync" className="checkbox-label">
<input
id="openrouterModelSync"
type="checkbox"
checked={form.openrouterModelSync !== false}
onChange={(e) => setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}
/>
Sync OpenRouter model list at dashboard startup
</label>
<small>
When enabled, the dashboard fetches the latest available models from the OpenRouter
API on startup, so the model picker always shows the most up-to-date catalog. Disable
to skip the initial API call and use only the built-in model list.
</small>
</div>
{/* --- AI Summarization --- */}
<h4 className="settings-section-heading" style={{ marginTop: "1.5rem" }}>AI Summarization</h4>
<div className="form-group">

View File

@@ -1139,6 +1139,25 @@ describe("SettingsModal", () => {
expect(payload.ntfyEnabled).toBe(true);
});
it("openrouterModelSync checkbox defaults to enabled and sends false when toggled off", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
const checkbox = screen.getByLabelText("Sync OpenRouter model list at dashboard startup");
expect(checkbox).toBeTruthy();
expect((checkbox as HTMLInputElement).checked).toBe(true);
fireEvent.click(checkbox);
expect((checkbox as HTMLInputElement).checked).toBe(false);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.openrouterModelSync).toBe(false);
});
it("ntfy topic field saves correctly when set", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());

View File

@@ -0,0 +1,478 @@
/**
* Mission Interview Session Management
*
* Manages AI-guided interview sessions for mission specification.
* Mirrors the planning session architecture but produces mission hierarchy
* data (milestones, slices, features) instead of task summaries.
*
* Sessions are stored in-memory with TTL cleanup.
*
* Features:
* - Stubbed question flow (scope -> objectives -> dependencies -> summary)
* - Rate limiting per IP
* - Session expiration and cleanup
* - SSE streaming via MissionInterviewStreamManager
*/
import type { PlanningQuestion } from "@fusion/core";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
// ── Constants ───────────────────────────────────────────────────────────────
/** Session TTL in milliseconds (30 minutes) */
const SESSION_TTL_MS = 30 * 60 * 1000;
/** Cleanup interval in milliseconds (5 minutes) */
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
/** Max interview sessions per IP per hour */
const MAX_SESSIONS_PER_IP_PER_HOUR = 5;
/** Rate limiting window in milliseconds (1 hour) */
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
// ── Types ───────────────────────────────────────────────────────────────────
/** A feature within a slice in the generated plan */
export interface MissionPlanFeature {
title: string;
description?: string;
acceptanceCriteria?: string;
}
/** A slice within a milestone in the generated plan */
export interface MissionPlanSlice {
title: string;
description?: string;
features: MissionPlanFeature[];
}
/** A milestone in the generated plan */
export interface MissionPlanMilestone {
title: string;
description?: string;
slices: MissionPlanSlice[];
}
/** The complete mission plan summary produced by the interview */
export interface MissionPlanSummary {
milestones: MissionPlanMilestone[];
}
/** Response from interview: either a question or a completed plan */
export type MissionInterviewResponse =
| { type: "question"; data: PlanningQuestion }
| { type: "complete"; data: MissionPlanSummary };
/** SSE event types for mission interview streaming */
export type MissionInterviewStreamEvent =
| { type: "thinking"; data: string }
| { type: "question"; data: PlanningQuestion }
| { type: "summary"; data: MissionPlanSummary }
| { type: "error"; data: string }
| { type: "complete" };
/** Callback function for streaming events */
export type MissionInterviewStreamCallback = (event: MissionInterviewStreamEvent) => void;
/** In-memory interview session */
interface MissionInterviewSession {
id: string;
ip: string;
missionId: string;
missionTitle: string;
history: Array<{ question: PlanningQuestion; response: unknown }>;
currentQuestion?: PlanningQuestion;
summary?: MissionPlanSummary;
createdAt: Date;
updatedAt: Date;
}
interface RateLimitEntry {
count: number;
firstRequestAt: Date;
}
// ── In-Memory Storage ───────────────────────────────────────────────────────
const sessions = new Map<string, MissionInterviewSession>();
const rateLimits = new Map<string, RateLimitEntry>();
// ── Cleanup Interval ────────────────────────────────────────────────────────
function cleanupExpiredSessions(): void {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
missionInterviewStreamManager.cleanupSession(id);
sessions.delete(id);
}
}
for (const [ip, entry] of rateLimits) {
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
rateLimits.delete(ip);
}
}
}
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
process.on("beforeExit", () => clearInterval(cleanupInterval));
// ── Stream Manager ──────────────────────────────────────────────────────────
export class MissionInterviewStreamManager extends EventEmitter {
private sessions = new Map<string, Set<MissionInterviewStreamCallback>>();
subscribe(sessionId: string, callback: MissionInterviewStreamCallback): () => void {
if (!this.sessions.has(sessionId)) {
this.sessions.set(sessionId, new Set());
}
const callbacks = this.sessions.get(sessionId)!;
callbacks.add(callback);
return () => {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.sessions.delete(sessionId);
}
};
}
broadcast(sessionId: string, event: MissionInterviewStreamEvent): void {
const callbacks = this.sessions.get(sessionId);
if (!callbacks) return;
for (const callback of callbacks) {
try {
callback(event);
} catch (err) {
console.error(`[mission-interview] Error broadcasting to client for session ${sessionId}:`, err);
}
}
}
hasSubscribers(sessionId: string): boolean {
const callbacks = this.sessions.get(sessionId);
return callbacks !== undefined && callbacks.size > 0;
}
cleanupSession(sessionId: string): void {
this.sessions.delete(sessionId);
}
}
export const missionInterviewStreamManager = new MissionInterviewStreamManager();
// ── Rate Limiting ───────────────────────────────────────────────────────────
export function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimits.get(ip);
if (!entry) {
rateLimits.set(ip, { count: 1, firstRequestAt: new Date() });
return true;
}
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
rateLimits.set(ip, { count: 1, firstRequestAt: new Date() });
return true;
}
if (entry.count >= MAX_SESSIONS_PER_IP_PER_HOUR) {
return false;
}
entry.count++;
return true;
}
export function getRateLimitResetTime(ip: string): Date | null {
const entry = rateLimits.get(ip);
if (!entry) return null;
return new Date(entry.firstRequestAt.getTime() + RATE_LIMIT_WINDOW_MS);
}
// ── Stubbed Question Generation ─────────────────────────────────────────────
function generateFirstQuestion(missionTitle: string): PlanningQuestion {
return {
id: "q-scope",
type: "single_select",
question: `What is the scope of "${missionTitle}"?`,
description: "This helps determine how many milestones and slices the mission needs.",
options: [
{ id: "small", label: "Small - 1 milestone, 1-2 slices", description: "Focused objective" },
{ id: "medium", label: "Medium - 2-3 milestones, multiple slices", description: "Standard project" },
{ id: "large", label: "Large - 3+ milestones, many slices", description: "Complex initiative" },
],
};
}
function generateNextQuestionOrSummary(session: MissionInterviewSession): MissionInterviewResponse {
const historyLength = session.history.length;
if (historyLength < 2) {
return {
type: "question",
data: {
id: "q-objectives",
type: "text",
question: "What are the key objectives or deliverables for this mission?",
description: "Describe the main things that need to be built or achieved. Each objective may become a milestone.",
},
};
}
if (historyLength < 3) {
return {
type: "question",
data: {
id: "q-confirm",
type: "confirm",
question: "Are there dependencies between the milestones that require a specific ordering?",
description: "If yes, the milestones will be ordered sequentially. Otherwise they can be worked in parallel.",
},
};
}
return { type: "complete", data: generateMissionPlanSummary(session) };
}
function generateMissionPlanSummary(session: MissionInterviewSession): MissionPlanSummary {
const scopeResponse = session.history.find((h) => h.question.id === "q-scope")?.response as
| Record<string, unknown>
| undefined;
const scope = (scopeResponse?.["q-scope"] as string) || "medium";
const objectivesResponse = session.history.find((h) => h.question.id === "q-objectives")?.response as
| Record<string, unknown>
| undefined;
const objectives = (objectivesResponse?.["q-objectives"] as string) || "";
// Generate hierarchy based on scope
if (scope === "small") {
return {
milestones: [
{
title: `${session.missionTitle} - Core Implementation`,
description: objectives || undefined,
slices: [
{
title: "Implementation",
description: `Core implementation for ${session.missionTitle}`,
features: [
{ title: "Core functionality", description: "Implement the main feature" },
{ title: "Tests", description: "Add test coverage" },
],
},
],
},
],
};
}
if (scope === "large") {
return {
milestones: [
{
title: "Foundation & Setup",
description: "Initial scaffolding and infrastructure",
slices: [
{
title: "Infrastructure",
features: [
{ title: "Project scaffolding", description: "Set up project structure" },
{ title: "Configuration", description: "Configure build and tooling" },
],
},
],
},
{
title: "Core Implementation",
description: objectives || "Main feature development",
slices: [
{
title: "Primary features",
features: [
{ title: "Core feature 1", description: "First major deliverable" },
{ title: "Core feature 2", description: "Second major deliverable" },
],
},
{
title: "Secondary features",
features: [
{ title: "Supporting feature", description: "Supporting functionality" },
],
},
],
},
{
title: "Polish & Release",
description: "Testing, documentation, and release preparation",
slices: [
{
title: "Quality assurance",
features: [
{ title: "Integration tests", description: "End-to-end test coverage" },
{ title: "Documentation", description: "User and developer documentation" },
],
},
],
},
],
};
}
// Medium (default)
return {
milestones: [
{
title: "Phase 1 - Setup & Core",
description: "Initial setup and core functionality",
slices: [
{
title: "Core implementation",
description: objectives || `Core work for ${session.missionTitle}`,
features: [
{ title: "Core functionality", description: "Implement the main feature" },
{ title: "Basic tests", description: "Add initial test coverage" },
],
},
],
},
{
title: "Phase 2 - Integration & Delivery",
description: "Integration, polish, and delivery",
slices: [
{
title: "Integration",
features: [
{ title: "Integration work", description: "Connect components together" },
{ title: "Final tests & docs", description: "Complete test coverage and documentation" },
],
},
],
},
],
};
}
// ── Session Management ──────────────────────────────────────────────────────
export async function createMissionInterviewSession(
ip: string,
missionId: string,
missionTitle: string
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
if (!checkRateLimit(ip)) {
const resetTime = getRateLimitResetTime(ip);
throw new RateLimitError(
`Rate limit exceeded. Maximum ${MAX_SESSIONS_PER_IP_PER_HOUR} sessions per hour. ` +
`Reset at ${resetTime?.toISOString() || "unknown"}`
);
}
const sessionId = randomUUID();
const firstQuestion = generateFirstQuestion(missionTitle);
const session: MissionInterviewSession = {
id: sessionId,
ip,
missionId,
missionTitle,
history: [],
currentQuestion: firstQuestion,
createdAt: new Date(),
updatedAt: new Date(),
};
sessions.set(sessionId, session);
return { sessionId, firstQuestion };
}
export async function submitMissionInterviewResponse(
sessionId: string,
responses: Record<string, unknown>
): Promise<MissionInterviewResponse> {
const session = sessions.get(sessionId);
if (!session) {
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
}
if (!session.currentQuestion) {
throw new InvalidSessionStateError("No active question in session");
}
session.history.push({
question: session.currentQuestion,
response: responses,
});
const result = generateNextQuestionOrSummary(session);
if (result.type === "question") {
session.currentQuestion = result.data;
} else {
session.summary = result.data;
session.currentQuestion = undefined;
}
session.updatedAt = new Date();
return result;
}
export async function cancelMissionInterviewSession(sessionId: string): Promise<void> {
const session = sessions.get(sessionId);
if (!session) {
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
}
missionInterviewStreamManager.cleanupSession(sessionId);
sessions.delete(sessionId);
}
export function getMissionInterviewSession(sessionId: string): MissionInterviewSession | undefined {
return sessions.get(sessionId);
}
export function getMissionInterviewSummary(sessionId: string): MissionPlanSummary | undefined {
return sessions.get(sessionId)?.summary;
}
export function cleanupMissionInterviewSession(sessionId: string): void {
missionInterviewStreamManager.cleanupSession(sessionId);
sessions.delete(sessionId);
}
/**
* Reset all mission interview state. Used for testing only.
*/
export function __resetMissionInterviewState(): void {
sessions.clear();
rateLimits.clear();
missionInterviewStreamManager.removeAllListeners();
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class RateLimitError extends Error {
constructor(message: string) {
super(message);
this.name = "RateLimitError";
}
}
export class SessionNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "SessionNotFoundError";
}
}
export class InvalidSessionStateError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidSessionStateError";
}
}