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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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());
|
||||
|
||||
478
packages/dashboard/src/mission-interview.ts
Normal file
478
packages/dashboard/src/mission-interview.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user