Mirror the planning.ts fix: capture store/rootDir on MissionInterviewSession at creation, fall back to the captured values in ensureMissionInterviewAgent when callers don't plumb them through. Then update the session/mission test files so their createSession, createMissionInterviewSession, and rehydrated submitResponse/retry calls pass a real TaskStore where they were previously passing undefined. Fixes 10 failing tests across mission-interview.test.ts, milestone-slice-interview.test.ts, session-error-recovery.test.ts, session-persistence-roundtrip.test.ts, session-resume-history.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1457 lines
52 KiB
TypeScript
1457 lines
52 KiB
TypeScript
/**
|
||
* Mission Interview Session Management
|
||
*
|
||
* Manages AI-guided interview sessions for mission specification.
|
||
* Uses an AI agent to conduct back-and-forth conversations that
|
||
* produce structured mission plans (milestones, slices, features).
|
||
*
|
||
* Architecture mirrors planning.ts but targets the mission hierarchy.
|
||
*
|
||
* Features:
|
||
* - AI agent integration with real-time streaming via SSE
|
||
* - Rate limiting per IP
|
||
* - Session expiration and cleanup
|
||
* - SSE streaming via MissionInterviewStreamManager
|
||
* - Prompt override support for project-level customization
|
||
*/
|
||
|
||
import type { PlanningQuestion, PromptOverrideMap, TaskStore } from "@fusion/core";
|
||
import { resolvePrompt } from "@fusion/core";
|
||
import { randomUUID } from "node:crypto";
|
||
import { EventEmitter } from "node:events";
|
||
import type { AiSessionStore, AiSessionRow, AiSessionStatus } from "./ai-session-store.js";
|
||
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
|
||
import {
|
||
createSessionDiagnostics,
|
||
resetDiagnosticsSink,
|
||
nonfatal,
|
||
} from "./ai-session-diagnostics.js";
|
||
import { GenerationGuard, isAbortError } from "./ai-session-timeout.js";
|
||
|
||
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
||
import { createPlanningBoardTools } from "./planning-board-tools.js";
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
type AgentResult = any;
|
||
const MISSION_INTERVIEW_BUILTIN_WEB_TOOLS = ["WebSearch", "WebFetch"] as const;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const createFnAgent: any = engineCreateFnAgent;
|
||
|
||
/**
|
||
* Shared diagnostics helper for the mission-interview module.
|
||
* Uses the shared ai-session-diagnostics helper for consistent scoped logging.
|
||
* @see ai-session-diagnostics.ts for the shared contract
|
||
*/
|
||
const diagnostics = createSessionDiagnostics("mission-interview");
|
||
|
||
/**
|
||
* Get the current diagnostics logger (for backward compatibility).
|
||
* @internal - exposed for test hook
|
||
*/
|
||
export function __getMissionInterviewDiagnostics() {
|
||
return diagnostics;
|
||
}
|
||
|
||
/**
|
||
* Inject a diagnostics sink (test-only).
|
||
* Delegates to the shared ai-session-diagnostics sink.
|
||
* When a sink is injected, all mission-interview module diagnostics route through it.
|
||
* This allows tests to assert on diagnostics without global console spies.
|
||
* @internal - exposed for test hook
|
||
*/
|
||
export function __setMissionInterviewDiagnostics(_logger: unknown): void {
|
||
// For backward compatibility, we keep this function but it now delegates
|
||
// to the shared helper's sink mechanism. The actual sink injection
|
||
// should use setDiagnosticsSink() from ai-session-diagnostics.
|
||
if (_logger === null) {
|
||
resetDiagnosticsSink();
|
||
}
|
||
}
|
||
|
||
function ensureEngineReady(): Promise<void> {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
// ── Constants ───────────────────────────────────────────────────────────────
|
||
|
||
/** Session TTL in milliseconds (7 days) */
|
||
const SESSION_TTL_MS = 7 * 24 * 60 * 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;
|
||
|
||
/** Max number of retry attempts when AI returns unparseable output */
|
||
const MAX_PARSE_RETRIES = 1;
|
||
|
||
/**
|
||
* Per-turn generation timeout. Mission interview turns produce larger plans
|
||
* than planning, so this is more generous than planning's 120 s. Bounds the
|
||
* worst case for a silently-stalled model stream or hung tool call.
|
||
*/
|
||
export const GENERATION_TIMEOUT_MS = 180_000;
|
||
|
||
const generationGuard = new GenerationGuard();
|
||
|
||
const MISSION_INTERVIEW_DRAFT_STATUSES = ["generating", "awaiting_input", "error"] as const;
|
||
|
||
function isMissionInterviewDraftStatus(
|
||
status: AiSessionStatus,
|
||
): status is Extract<AiSessionStatus, (typeof MISSION_INTERVIEW_DRAFT_STATUSES)[number]> {
|
||
return (MISSION_INTERVIEW_DRAFT_STATUSES as readonly string[]).includes(status);
|
||
}
|
||
|
||
/** Mission interview system prompt */
|
||
export const MISSION_INTERVIEW_SYSTEM_PROMPT = `You are a mission planning assistant for a project management system.
|
||
|
||
Your job: help users transform high-level goals into structured mission plans with milestones, slices, and features — each with verification criteria.
|
||
|
||
## Mission Hierarchy
|
||
- Mission: The top-level objective (the user will provide this)
|
||
- Milestone: A major phase or deliverable within the mission (e.g., "Foundation & Infrastructure", "Core Feature Development", "Polish & Release"). Each milestone has verification criteria that define how to confirm the phase is complete.
|
||
- Slice: A focused work unit within a milestone that can be activated and worked on independently (e.g., "Auth system setup", "API endpoints", "UI components"). Each slice has verification criteria.
|
||
- Feature: A specific deliverable within a slice, detailed enough to become a task (e.g., "JWT token refresh endpoint", "Password reset email template"). Each feature has acceptance criteria.
|
||
|
||
## Conversation Flow
|
||
1. The user describes their mission goal
|
||
2. Ask clarifying questions to understand scope, constraints, technical context, user needs, and priorities
|
||
3. Push back on vague objectives — ask for specifics
|
||
4. Challenge unrealistic scope — suggest phasing
|
||
5. Once you have enough information (typically 4-8 questions), produce the structured plan
|
||
6. The plan should be thorough — break every milestone into slices, every slice into features
|
||
|
||
## Question Types to Use
|
||
PREFER structured question types over free-text. This makes the interview faster and more focused.
|
||
|
||
- "single_select" (DEFAULT): Use for most questions. Provide 3–6 options covering the common choices.
|
||
ALWAYS include an "Other (please describe)" or "Custom" option as the LAST option so users can provide free-form input.
|
||
Examples: tech stack, deployment target, priority level, integration approach, architecture style.
|
||
- "multi_select": Use when multiple options can apply simultaneously (e.g., features to include, platforms to support, security requirements).
|
||
Provide 4–6 options. Include an "Other (please describe)" option at the end.
|
||
- "confirm": Use for simple yes/no or go/no-go decisions (e.g., "Should we include offline support?", "Is backwards compatibility required?").
|
||
- "text": Use ONLY when genuinely needed — asking for a project name, URL, specific API endpoint, or other unique free-form values that cannot be reasonably optioned.
|
||
|
||
## Question Design Guidelines
|
||
- Provide specific, well-crafted option labels and descriptions so users can quickly select without thinking
|
||
- Options should be mutually exclusive and collectively exhaustive for single_select
|
||
- Use domain-appropriate jargon in option labels (developers understand "GraphQL", "REST", "gRPC")
|
||
- Include 3–6 options per question — never fewer than 3, rarely more than 6
|
||
- The last option should always be something like { "id": "other", "label": "Other (please describe)" } for single_select, or similar for multi_select
|
||
- Start with big-picture scope questions, then narrow into specifics
|
||
- Ask about target users, key constraints, technical preferences, timeline
|
||
- Each milestone should represent a meaningful phase boundary or checkpoint
|
||
- Each slice should be independently shippable work
|
||
- Features should be specific and actionable
|
||
- ALWAYS include verification/acceptance criteria at every level:
|
||
- Milestone: "verification" field — how to confirm this phase is complete (e.g., "All API endpoints return correct responses, integration tests pass")
|
||
- Milestone: optional "acceptanceCriteria" field for explicit milestone-level completion bars (if omitted, acceptance can be auto-derived from child feature criteria/descriptions)
|
||
- Slice: "verification" field — how to confirm this work unit is done (e.g., "Auth flow works end-to-end from signup through login")
|
||
- Feature: "acceptanceCriteria" field — how to verify this specific deliverable (e.g., "JWT tokens expire after 1 hour and refresh correctly")
|
||
- Suggest sensible defaults and push for specificity
|
||
- Aim for 2-4 milestones, 1-3 slices per milestone, 2-5 features per slice
|
||
- Keep the plan realistic and achievable
|
||
|
||
## Board tools
|
||
- fn_task_list — list active tasks
|
||
- fn_task_get — read a task's full details and PROMPT.md
|
||
Use these to avoid duplicating an existing in-flight plan and to anchor your questions against current backlog context.
|
||
|
||
## Response Format
|
||
Always respond with valid JSON in one of these formats:
|
||
|
||
For single_select question (DEFAULT — use this for most questions):
|
||
{"type": "question", "data": {"id": "q-tech-stack", "type": "single_select", "question": "What is the primary technology stack?", "description": "Select the main framework or stack for the project", "options": [{"id": "react-ts", "label": "React + TypeScript", "description": "Component-based UI with type safety"}, {"id": "nextjs", "label": "Next.js", "description": "Full-stack React framework with SSR"}, {"id": "vue", "label": "Vue.js", "description": "Progressive JavaScript framework"}, {"id": "backend-only", "label": "Backend / API only", "description": "No frontend, pure server-side project"}, {"id": "other", "label": "Other (please describe)", "description": "Specify your custom stack"}]}}
|
||
|
||
For multi_select question:
|
||
{"type": "question", "data": {"id": "q-platforms", "type": "multi_select", "question": "Which platforms should be supported?", "description": "Select all that apply", "options": [{"id": "web", "label": "Web browser", "description": "Desktop and mobile web"}, {"id": "ios", "label": "iOS", "description": "iPhone and iPad"}, {"id": "android", "label": "Android", "description": "Phones and tablets"}, {"id": "desktop", "label": "Desktop app", "description": "Electron or native desktop"}, {"id": "api", "label": "API / headless", "description": "Programmatic access only"}, {"id": "other", "label": "Other (please describe)", "description": "Specify additional platforms"}]}}
|
||
|
||
For confirm question:
|
||
{"type": "question", "data": {"id": "q-auth", "type": "confirm", "question": "Should the system require user authentication?", "description": "Login, sessions, and access control"}}
|
||
|
||
For text question (use ONLY for names, URLs, or truly unique free-form input):
|
||
{"type": "question", "data": {"id": "q-project-name", "type": "text", "question": "What is the project or product name?", "description": "Used in documentation and configuration"}}
|
||
|
||
For completion (when you have enough information):
|
||
{"type": "complete", "data": {"missionTitle": "Refined mission title", "missionDescription": "Comprehensive mission description based on the conversation", "milestones": [{"title": "Milestone title", "description": "What this phase achieves", "verification": "How to confirm this milestone is complete", "acceptanceCriteria": "Optional explicit milestone criteria (omit to auto-derive from features)", "slices": [{"title": "Slice title", "description": "What this work unit covers", "verification": "How to confirm this slice is done", "features": [{"title": "Feature title", "description": "What to build", "acceptanceCriteria": "How to verify this feature works"}]}]}]}}`;
|
||
|
||
// ── 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;
|
||
verification?: string;
|
||
features: MissionPlanFeature[];
|
||
}
|
||
|
||
/** A milestone in the generated plan */
|
||
export interface MissionPlanMilestone {
|
||
title: string;
|
||
description?: string;
|
||
verification?: string;
|
||
acceptanceCriteria?: string;
|
||
slices: MissionPlanSlice[];
|
||
}
|
||
|
||
/** The complete mission plan summary produced by the interview */
|
||
export interface MissionPlanSummary {
|
||
missionTitle?: string;
|
||
missionDescription?: string;
|
||
milestones: MissionPlanMilestone[];
|
||
}
|
||
|
||
/** Response from interview: either a question or a completed plan */
|
||
export type MissionInterviewResponse =
|
||
| { type: "question"; data: PlanningQuestion }
|
||
| { type: "complete"; data: MissionPlanSummary };
|
||
|
||
export interface MissionInterviewDraftSummary {
|
||
id: string;
|
||
title: string;
|
||
status: Extract<AiSessionStatus, (typeof MISSION_INTERVIEW_DRAFT_STATUSES)[number]>;
|
||
projectId: string | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
hasConversation: boolean;
|
||
}
|
||
|
||
/** SSE event types for mission interview streaming */
|
||
export type MissionInterviewStreamEvent =
|
||
| { type: "thinking"; data: string }
|
||
| { type: "text"; 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, eventId?: number) => void;
|
||
|
||
interface MissionInterviewHistoryEntry {
|
||
question: PlanningQuestion;
|
||
response: unknown;
|
||
thinkingOutput?: string;
|
||
}
|
||
|
||
/** In-memory interview session */
|
||
interface MissionInterviewSession {
|
||
id: string;
|
||
ip: string;
|
||
projectId: string | null;
|
||
missionId: string;
|
||
missionTitle: string;
|
||
history: MissionInterviewHistoryEntry[];
|
||
currentQuestion?: PlanningQuestion;
|
||
summary?: MissionPlanSummary;
|
||
/** Last terminal error for retry UX */
|
||
error?: string;
|
||
agent?: AgentResult;
|
||
thinkingOutput: string;
|
||
/** Thinking output generated while producing currentQuestion */
|
||
lastGeneratedThinking: string;
|
||
/** Model override for this interview session */
|
||
modelProvider?: string;
|
||
modelId?: string;
|
||
/**
|
||
* Captured at session creation so rehydrated sessions can rebuild their
|
||
* agent without the caller threading context through every API.
|
||
*/
|
||
store?: TaskStore;
|
||
rootDir?: string;
|
||
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>();
|
||
|
||
// ── AI Session Persistence ────────────────────────────────────────────────
|
||
|
||
let _aiSessionStore: AiSessionStore | undefined;
|
||
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
|
||
|
||
function safeParseJson<T>(
|
||
text: string | null,
|
||
fallback: T,
|
||
options?: { throwOnError?: boolean; fieldName?: string },
|
||
): T {
|
||
if (!text) {
|
||
return fallback;
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(text) as T;
|
||
} catch (error) {
|
||
if (options?.throwOnError) {
|
||
const fieldSuffix = options.fieldName ? ` in ${options.fieldName}` : "";
|
||
throw new Error(`Invalid JSON${fieldSuffix}: ${(error as Error).message}`);
|
||
}
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
export function setAiSessionStore(store: AiSessionStore): void {
|
||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||
}
|
||
|
||
_aiSessionStore = store;
|
||
_aiSessionDeletedListener = (sessionId: string) => {
|
||
cleanupInMemoryMissionSession(sessionId);
|
||
};
|
||
_aiSessionStore.on("ai_session:deleted", _aiSessionDeletedListener);
|
||
}
|
||
|
||
function cleanupInMemoryMissionSession(sessionId: string): boolean {
|
||
const session = sessions.get(sessionId);
|
||
if (!session) {
|
||
return false;
|
||
}
|
||
|
||
// Abort any in-flight generation so prompt() rejects promptly.
|
||
generationGuard.stop(sessionId);
|
||
|
||
if (session.agent) {
|
||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||
session.agent = undefined;
|
||
}
|
||
|
||
missionInterviewStreamManager.cleanupSession(sessionId);
|
||
sessions.delete(sessionId);
|
||
return true;
|
||
}
|
||
|
||
function persistMissionSession(session: MissionInterviewSession, status: "generating" | "awaiting_input" | "complete" | "error", error?: string): void {
|
||
if (!_aiSessionStore) return;
|
||
const row: AiSessionRow = {
|
||
id: session.id,
|
||
type: "mission_interview",
|
||
status,
|
||
title: session.missionTitle.slice(0, 120),
|
||
inputPayload: JSON.stringify({
|
||
ip: session.ip,
|
||
projectId: session.projectId,
|
||
missionTitle: session.missionTitle,
|
||
missionId: session.missionId,
|
||
modelProvider: session.modelProvider,
|
||
modelId: session.modelId,
|
||
}),
|
||
conversationHistory: JSON.stringify(session.history),
|
||
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
|
||
result: session.summary ? JSON.stringify(session.summary) : null,
|
||
thinkingOutput: session.thinkingOutput,
|
||
error: error ?? null,
|
||
projectId: session.projectId,
|
||
createdAt: session.createdAt.toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
lockedByTab: null,
|
||
lockedAt: null,
|
||
};
|
||
_aiSessionStore.upsert(row);
|
||
}
|
||
|
||
function persistMissionThinking(sessionId: string, thinkingOutput: string): void {
|
||
if (!_aiSessionStore) return;
|
||
_aiSessionStore.updateThinking(sessionId, thinkingOutput);
|
||
}
|
||
|
||
function unpersistMissionSession(sessionId: string): void {
|
||
if (!_aiSessionStore) return;
|
||
_aiSessionStore.delete(sessionId);
|
||
}
|
||
|
||
function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionInterviewSession {
|
||
const payload = safeParseJson<{ ip?: string; projectId?: string | null; missionId?: string; missionTitle?: string; modelProvider?: string; modelId?: string }>(
|
||
row.inputPayload,
|
||
{},
|
||
{ throwOnError: true, fieldName: "inputPayload" },
|
||
);
|
||
|
||
const createdAt = new Date(row.createdAt);
|
||
const updatedAt = new Date(row.updatedAt);
|
||
|
||
if (Number.isNaN(createdAt.getTime()) || Number.isNaN(updatedAt.getTime())) {
|
||
throw new Error("Invalid session timestamps");
|
||
}
|
||
|
||
return {
|
||
id: row.id,
|
||
ip: payload.ip ?? "",
|
||
projectId: row.projectId ?? payload.projectId ?? null,
|
||
missionId: payload.missionId ?? "",
|
||
missionTitle: payload.missionTitle ?? row.title,
|
||
history: safeParseJson<MissionInterviewHistoryEntry[]>(
|
||
row.conversationHistory,
|
||
[],
|
||
{ throwOnError: true, fieldName: "conversationHistory" },
|
||
),
|
||
currentQuestion: row.currentQuestion
|
||
? (safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
|
||
throwOnError: true,
|
||
fieldName: "currentQuestion",
|
||
}) ?? undefined)
|
||
: undefined,
|
||
summary: row.result
|
||
? (safeParseJson<MissionPlanSummary | null>(row.result, null, {
|
||
throwOnError: true,
|
||
fieldName: "result",
|
||
}) ?? undefined)
|
||
: undefined,
|
||
thinkingOutput: row.thinkingOutput,
|
||
lastGeneratedThinking: row.thinkingOutput || "",
|
||
error: row.error ?? undefined,
|
||
modelProvider: payload.modelProvider,
|
||
modelId: payload.modelId,
|
||
createdAt,
|
||
updatedAt,
|
||
agent: undefined,
|
||
};
|
||
}
|
||
|
||
export function rehydrateFromStore(store: AiSessionStore): number {
|
||
let rows: AiSessionRow[] = [];
|
||
|
||
try {
|
||
rows = store.listRecoverable().filter((row) => row.type === "mission_interview");
|
||
} catch (error) {
|
||
diagnostics.errorFromException("Failed to list recoverable sessions", error, { operation: "list-recoverable" });
|
||
return 0;
|
||
}
|
||
|
||
let rehydrated = 0;
|
||
for (const row of rows) {
|
||
try {
|
||
const session = buildMissionInterviewSessionFromRow(row);
|
||
sessions.set(session.id, session);
|
||
rehydrated += 1;
|
||
} catch (error) {
|
||
diagnostics.errorFromException("Failed to rehydrate session", error, { sessionId: row.id, operation: "rehydrate" });
|
||
}
|
||
}
|
||
|
||
return rehydrated;
|
||
}
|
||
|
||
// ── Cleanup Interval ────────────────────────────────────────────────────────
|
||
|
||
function cleanupExpiredSessions(): void {
|
||
const now = Date.now();
|
||
for (const [id, session] of sessions) {
|
||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||
cleanupInMemoryMissionSession(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);
|
||
cleanupInterval.unref?.();
|
||
process.on("beforeExit", () => clearInterval(cleanupInterval));
|
||
|
||
// ── Stream Manager ──────────────────────────────────────────────────────────
|
||
|
||
export class MissionInterviewStreamManager extends EventEmitter {
|
||
private readonly sessions = new Map<string, Set<MissionInterviewStreamCallback>>();
|
||
private readonly buffers = new Map<string, SessionEventBuffer>();
|
||
|
||
constructor(private readonly bufferSize = 100) {
|
||
super();
|
||
}
|
||
|
||
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);
|
||
}
|
||
};
|
||
}
|
||
|
||
private getBuffer(sessionId: string): SessionEventBuffer {
|
||
let buffer = this.buffers.get(sessionId);
|
||
if (!buffer) {
|
||
buffer = new SessionEventBuffer(this.bufferSize);
|
||
this.buffers.set(sessionId, buffer);
|
||
}
|
||
return buffer;
|
||
}
|
||
|
||
broadcast(sessionId: string, event: MissionInterviewStreamEvent): number {
|
||
const serialized = JSON.stringify((event as { data?: unknown }).data ?? {});
|
||
const eventData = typeof serialized === "string" ? serialized : "{}";
|
||
const eventId = this.getBuffer(sessionId).push(event.type, eventData);
|
||
|
||
const callbacks = this.sessions.get(sessionId);
|
||
if (!callbacks) return eventId;
|
||
|
||
for (const callback of callbacks) {
|
||
nonfatal(
|
||
() => callback(event, eventId),
|
||
diagnostics,
|
||
"Error broadcasting to client",
|
||
{ sessionId, operation: "broadcast" }
|
||
);
|
||
}
|
||
|
||
return eventId;
|
||
}
|
||
|
||
getBufferedEvents(sessionId: string, sinceId: number): SessionBufferedEvent[] {
|
||
const buffer = this.buffers.get(sessionId);
|
||
if (!buffer) return [];
|
||
return buffer.getEventsSince(sinceId);
|
||
}
|
||
|
||
hasSubscribers(sessionId: string): boolean {
|
||
const callbacks = this.sessions.get(sessionId);
|
||
return callbacks !== undefined && callbacks.size > 0;
|
||
}
|
||
|
||
cleanupSession(sessionId: string): void {
|
||
this.sessions.delete(sessionId);
|
||
this.buffers.delete(sessionId);
|
||
}
|
||
|
||
reset(): void {
|
||
this.sessions.clear();
|
||
this.buffers.clear();
|
||
this.removeAllListeners();
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// ── JSON Parsing Utilities ─────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Extract the best JSON candidate from AI response text.
|
||
* Handles markdown-wrapped JSON, embedded prose, and multiple objects.
|
||
*/
|
||
export function extractJsonCandidate(text: string): string | null {
|
||
if (!text || !text.trim()) return null;
|
||
|
||
// 1. Try markdown code blocks first
|
||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||
if (codeBlockMatch?.[1]) {
|
||
const candidate = codeBlockMatch[1].trim();
|
||
if (candidate.startsWith("{")) return candidate;
|
||
}
|
||
|
||
// 2. Find all top-level brace-delimited objects using balanced brace counting
|
||
const candidates: Array<{ text: string }> = [];
|
||
for (let i = 0; i < text.length; i++) {
|
||
if (text[i] === "{") {
|
||
let depth = 0;
|
||
let inString = false;
|
||
let escape = false;
|
||
for (let j = i; j < text.length; j++) {
|
||
const ch = text[j];
|
||
if (escape) { escape = false; continue; }
|
||
if (ch === "\\") { escape = true; continue; }
|
||
if (ch === '"') { inString = !inString; continue; }
|
||
if (inString) continue;
|
||
if (ch === "{") depth++;
|
||
if (ch === "}") depth--;
|
||
if (depth === 0) {
|
||
const candidate = text.slice(i, j + 1).trim();
|
||
try {
|
||
JSON.parse(candidate);
|
||
candidates.push({ text: candidate });
|
||
} catch { /* not valid JSON */ }
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (candidates.length > 0) {
|
||
candidates.sort((a, b) => b.text.length - a.text.length);
|
||
return candidates[0].text;
|
||
}
|
||
|
||
// 3. Last resort: try the full trimmed text
|
||
const trimmed = text.trim();
|
||
if (trimmed.startsWith("{")) return trimmed;
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Attempt to repair common JSON issues.
|
||
*/
|
||
export function repairJson(text: string): string {
|
||
let repaired = text;
|
||
repaired = repaired.replace(/,\s*([}\]])/g, "$1");
|
||
|
||
let openBraces = 0;
|
||
let openBrackets = 0;
|
||
let inString = false;
|
||
let escape = false;
|
||
for (const ch of repaired) {
|
||
if (escape) { escape = false; continue; }
|
||
if (ch === "\\") { escape = true; continue; }
|
||
if (ch === '"') { inString = !inString; continue; }
|
||
if (inString) continue;
|
||
if (ch === "{") openBraces++;
|
||
if (ch === "}") openBraces--;
|
||
if (ch === "[") openBrackets++;
|
||
if (ch === "]") openBrackets--;
|
||
}
|
||
|
||
if (inString) repaired += '"';
|
||
|
||
// Re-count after potential string fix
|
||
openBraces = 0;
|
||
openBrackets = 0;
|
||
inString = false;
|
||
escape = false;
|
||
for (const ch of repaired) {
|
||
if (escape) { escape = false; continue; }
|
||
if (ch === "\\") { escape = true; continue; }
|
||
if (ch === '"') { inString = !inString; continue; }
|
||
if (inString) continue;
|
||
if (ch === "{") openBraces++;
|
||
if (ch === "}") openBraces--;
|
||
if (ch === "[") openBrackets++;
|
||
if (ch === "]") openBrackets--;
|
||
}
|
||
|
||
repaired += "]".repeat(Math.max(0, openBrackets));
|
||
repaired += "}".repeat(Math.max(0, openBraces));
|
||
|
||
return repaired;
|
||
}
|
||
|
||
/**
|
||
* Parse AI agent response into a MissionInterviewResponse.
|
||
* Handles markdown wrapping, embedded prose, truncated JSON.
|
||
*/
|
||
export function parseMissionAgentResponse(text: string): MissionInterviewResponse {
|
||
const candidate = extractJsonCandidate(text);
|
||
|
||
if (!candidate) {
|
||
diagnostics.error("No JSON candidate found in agent response", { inputSnippet: text.slice(0, 500), operation: "parse-json" });
|
||
throw new Error("AI returned no valid JSON. Please try again.");
|
||
}
|
||
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(candidate);
|
||
} catch {
|
||
try {
|
||
const repaired = repairJson(candidate);
|
||
parsed = JSON.parse(repaired);
|
||
} catch (repairErr) {
|
||
diagnostics.error("Failed to parse agent response (repair also failed)", { inputSnippet: candidate.slice(0, 500), operation: "parse-json-repair" });
|
||
throw new Error(
|
||
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
|
||
);
|
||
}
|
||
}
|
||
|
||
if (
|
||
typeof parsed === "object" &&
|
||
parsed !== null &&
|
||
"type" in parsed &&
|
||
"data" in parsed
|
||
) {
|
||
const typed = parsed as { type: string; data: unknown };
|
||
if (typed.type === "question" && typed.data !== null && typed.data !== undefined) {
|
||
return parsed as MissionInterviewResponse;
|
||
}
|
||
if (typed.type === "complete" && typed.data !== null && typeof typed.data === "object") {
|
||
const data = typed.data as Record<string, unknown>;
|
||
if (Array.isArray(data.milestones)) {
|
||
return parsed as MissionInterviewResponse;
|
||
}
|
||
}
|
||
}
|
||
|
||
diagnostics.error("Invalid response structure from AI", { parsedSnippet: JSON.stringify(parsed).slice(0, 500), operation: "parse-validate" });
|
||
throw new Error("AI returned an invalid response structure. Please try again.");
|
||
}
|
||
|
||
// ── Response Formatting ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Format user response as a message for the AI agent.
|
||
*/
|
||
function formatResponseForAgent(
|
||
question: PlanningQuestion,
|
||
responses: Record<string, unknown>
|
||
): string {
|
||
const responseValue = responses[question.id];
|
||
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
||
|
||
let formatted: string;
|
||
|
||
switch (question.type) {
|
||
case "text":
|
||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||
break;
|
||
case "single_select":
|
||
if (typeof responseValue === "string") {
|
||
const option = question.options?.find((o) => o.id === responseValue);
|
||
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||
break;
|
||
}
|
||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||
break;
|
||
case "multi_select":
|
||
if (Array.isArray(responseValue)) {
|
||
const selected = responseValue.map((id) => {
|
||
const option = question.options?.find((o) => o.id === id);
|
||
return option?.label || id;
|
||
});
|
||
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||
break;
|
||
}
|
||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||
break;
|
||
case "confirm":
|
||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||
break;
|
||
default:
|
||
formatted = `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
|
||
break;
|
||
}
|
||
|
||
return comment.length > 0 ? `${formatted}\n\nAdditional context: ${comment}` : formatted;
|
||
}
|
||
|
||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||
if (response && typeof response === "object" && !Array.isArray(response)) {
|
||
return response as Record<string, unknown>;
|
||
}
|
||
|
||
return {
|
||
[question.id]: response,
|
||
};
|
||
}
|
||
|
||
function disposeMissionAgentForRetry(session: MissionInterviewSession): void {
|
||
if (!session.agent) {
|
||
return;
|
||
}
|
||
|
||
nonfatal(
|
||
() => session.agent.session.dispose?.(),
|
||
diagnostics,
|
||
"Error disposing agent for retry",
|
||
{ sessionId: session.id, operation: "dispose-retry" }
|
||
);
|
||
|
||
session.agent = undefined;
|
||
}
|
||
|
||
// ── AI Agent Integration ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Initialize the AI agent for a session and start the first turn.
|
||
*/
|
||
async function initializeAgent(
|
||
session: MissionInterviewSession,
|
||
rootDir: string,
|
||
store: TaskStore,
|
||
promptOverrides?: PromptOverrideMap,
|
||
): Promise<void> {
|
||
try {
|
||
session.agent = await createMissionInterviewAgent(session, rootDir, store, promptOverrides);
|
||
session.updatedAt = new Date();
|
||
|
||
// Send initial message to get first question
|
||
await continueAgentConversation(
|
||
session,
|
||
`I want to plan a mission: "${session.missionTitle}". Interview me to understand what I need, then produce a structured plan.`,
|
||
);
|
||
} catch (err) {
|
||
const errorMessage = err instanceof Error ? err.message : "Failed to initialize AI agent";
|
||
diagnostics.errorFromException("Agent initialization error for session", err, { sessionId: session.id, operation: "initialize-agent" });
|
||
session.error = errorMessage;
|
||
session.updatedAt = new Date();
|
||
persistMissionSession(session, "error", errorMessage);
|
||
missionInterviewStreamManager.broadcast(session.id, {
|
||
type: "error",
|
||
data: errorMessage,
|
||
});
|
||
}
|
||
}
|
||
|
||
async function createMissionInterviewAgent(
|
||
session: MissionInterviewSession,
|
||
rootDir: string,
|
||
store: TaskStore,
|
||
promptOverrides?: PromptOverrideMap,
|
||
): Promise<AgentResult> {
|
||
await ensureEngineReady();
|
||
|
||
const effectivePrompt = resolvePrompt("mission-interview-system", promptOverrides);
|
||
|
||
return createFnAgent({
|
||
cwd: rootDir,
|
||
systemPrompt: effectivePrompt,
|
||
tools: "readonly",
|
||
builtinToolsAllowlist: [...MISSION_INTERVIEW_BUILTIN_WEB_TOOLS],
|
||
customTools: [...createPlanningBoardTools(store)],
|
||
...(session.modelProvider && session.modelId
|
||
? {
|
||
defaultProvider: session.modelProvider,
|
||
defaultModelId: session.modelId,
|
||
}
|
||
: {}),
|
||
onThinking: (delta: string) => {
|
||
session.thinkingOutput += delta;
|
||
persistMissionThinking(session.id, session.thinkingOutput);
|
||
missionInterviewStreamManager.broadcast(session.id, {
|
||
type: "thinking",
|
||
data: delta,
|
||
});
|
||
},
|
||
onText: (delta: string) => {
|
||
session.thinkingOutput += delta;
|
||
missionInterviewStreamManager.broadcast(session.id, {
|
||
type: "text",
|
||
data: delta,
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
function formatMissionInterviewHistory(
|
||
history: Array<{ question: PlanningQuestion; response: unknown }>,
|
||
): string {
|
||
if (history.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
return history
|
||
.map(({ question, response }) => {
|
||
const responseRecord =
|
||
response && typeof response === "object" && !Array.isArray(response)
|
||
? (response as Record<string, unknown>)
|
||
: undefined;
|
||
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
||
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
||
|
||
const lines = [
|
||
`Q: ${question.question}`,
|
||
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`,
|
||
];
|
||
|
||
if (comment.length > 0) {
|
||
lines.push(`Comment: ${comment}`);
|
||
}
|
||
|
||
return lines.join("\n");
|
||
})
|
||
.join("\n\n");
|
||
}
|
||
|
||
async function ensureMissionInterviewAgent(
|
||
session: MissionInterviewSession,
|
||
rootDir: string | undefined,
|
||
store: TaskStore | undefined,
|
||
historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>,
|
||
promptOverrides?: PromptOverrideMap,
|
||
): Promise<void> {
|
||
if (session.agent) {
|
||
return;
|
||
}
|
||
|
||
const effectiveRootDir = rootDir ?? session.rootDir;
|
||
const effectiveStore = store ?? session.store;
|
||
|
||
if (!effectiveRootDir) {
|
||
throw new InvalidSessionStateError(
|
||
"AI agent not available for this session and cannot be resumed without project context",
|
||
);
|
||
}
|
||
if (!effectiveStore) {
|
||
throw new InvalidSessionStateError(
|
||
"AI agent not available for this session and cannot be resumed without task store context",
|
||
);
|
||
}
|
||
|
||
session.agent = await createMissionInterviewAgent(session, effectiveRootDir, effectiveStore, promptOverrides);
|
||
|
||
if (historyForReplay.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const historySummary = formatMissionInterviewHistory(historyForReplay);
|
||
if (!historySummary) {
|
||
return;
|
||
}
|
||
|
||
await generationGuard.run(
|
||
session.id,
|
||
GENERATION_TIMEOUT_MS,
|
||
{
|
||
onTimeout: () => setMissionSessionError(
|
||
session,
|
||
"AI generation timed out while restoring context. You can retry or start a new session.",
|
||
),
|
||
onUserStop: () => setMissionSessionError(
|
||
session,
|
||
"Generation stopped by user. You can retry or start a new session.",
|
||
),
|
||
},
|
||
() => session.agent!.session.prompt(
|
||
[
|
||
"Previous conversation summary:",
|
||
historySummary,
|
||
"Use this context when handling the next user response.",
|
||
].join("\n\n"),
|
||
),
|
||
);
|
||
}
|
||
|
||
function setMissionSessionError(session: MissionInterviewSession, message: string): void {
|
||
session.error = message;
|
||
session.updatedAt = new Date();
|
||
persistMissionSession(session, "error", message);
|
||
missionInterviewStreamManager.broadcast(session.id, {
|
||
type: "error",
|
||
data: message,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Manually abort an in-flight mission interview generation. Returns true if
|
||
* a generation was active and got aborted.
|
||
*/
|
||
export function stopMissionInterviewGeneration(sessionId: string): boolean {
|
||
return generationGuard.stop(sessionId);
|
||
}
|
||
|
||
/**
|
||
* Continue the AI conversation with a user message.
|
||
* Includes bounded recovery: one retry on parse failure.
|
||
*
|
||
* The entire body — including the parse-retry inner `prompt()` calls — runs
|
||
* inside `generationGuard.run`, so a stalled stream or hung tool call on
|
||
* either the first or retry attempt is bounded by `GENERATION_TIMEOUT_MS`.
|
||
*/
|
||
async function continueAgentConversation(session: MissionInterviewSession, message: string): Promise<void> {
|
||
if (!session.agent) {
|
||
throw new InvalidSessionStateError("AI agent not initialized");
|
||
}
|
||
|
||
try {
|
||
await generationGuard.run(
|
||
session.id,
|
||
GENERATION_TIMEOUT_MS,
|
||
{
|
||
onTimeout: () => setMissionSessionError(
|
||
session,
|
||
"AI generation timed out. You can retry or start a new session.",
|
||
),
|
||
onUserStop: () => setMissionSessionError(
|
||
session,
|
||
"Generation stopped by user. You can retry or start a new session.",
|
||
),
|
||
},
|
||
async () => {
|
||
const agent = session.agent!;
|
||
session.thinkingOutput = "";
|
||
|
||
await agent.session.prompt(message);
|
||
|
||
// Get the response text from the agent's state
|
||
interface AgentMessage {
|
||
role: string;
|
||
content?: string | Array<{ type: string; text: string }>;
|
||
}
|
||
const lastMessage = (agent.session.state.messages as AgentMessage[])
|
||
.filter((m: AgentMessage) => m.role === "assistant")
|
||
.pop();
|
||
|
||
let responseText = session.thinkingOutput;
|
||
if (lastMessage?.content) {
|
||
if (typeof lastMessage.content === "string") {
|
||
responseText = lastMessage.content;
|
||
} else if (Array.isArray(lastMessage.content)) {
|
||
responseText = lastMessage.content
|
||
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||
.map((c: { type: string; text: string }) => c.text)
|
||
.join("");
|
||
}
|
||
}
|
||
|
||
// Parse with retry
|
||
let parsed: MissionInterviewResponse | undefined;
|
||
let lastError: Error | undefined;
|
||
|
||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||
try {
|
||
parsed = parseMissionAgentResponse(responseText);
|
||
break;
|
||
} catch (err) {
|
||
lastError = err instanceof Error ? err : new Error(String(err));
|
||
|
||
if (attempt < MAX_PARSE_RETRIES) {
|
||
diagnostics.warn(
|
||
"Parse attempt failed, requesting reformat",
|
||
{ sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
|
||
);
|
||
try {
|
||
session.thinkingOutput = "";
|
||
await agent.session.prompt(
|
||
"Your previous response could not be parsed as JSON. " +
|
||
'Please respond with ONLY a valid JSON object: either {"type":"question","data":{...}} ' +
|
||
'or {"type":"complete","data":{"missionTitle":"...","missionDescription":"...","milestones":[...]}}. ' +
|
||
"No markdown, no explanation, just the JSON."
|
||
);
|
||
|
||
const retryMessage = (agent.session.state.messages as AgentMessage[])
|
||
.filter((m: AgentMessage) => m.role === "assistant")
|
||
.pop();
|
||
|
||
let retryText = session.thinkingOutput;
|
||
if (retryMessage?.content) {
|
||
if (typeof retryMessage.content === "string") {
|
||
retryText = retryMessage.content;
|
||
} else if (Array.isArray(retryMessage.content)) {
|
||
retryText = retryMessage.content
|
||
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||
.map((c: { type: string; text: string }) => c.text)
|
||
.join("");
|
||
}
|
||
}
|
||
responseText = retryText;
|
||
} catch (retryErr) {
|
||
diagnostics.errorFromException("Retry prompt failed for session", retryErr, { sessionId: session.id, operation: "retry-prompt" });
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!parsed) {
|
||
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new session.`;
|
||
diagnostics.error(
|
||
"All parse attempts exhausted for session",
|
||
{ sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
|
||
);
|
||
setMissionSessionError(session, errorMsg);
|
||
return;
|
||
}
|
||
|
||
if (parsed.type === "question") {
|
||
session.currentQuestion = parsed.data;
|
||
session.error = undefined;
|
||
session.lastGeneratedThinking = session.thinkingOutput;
|
||
session.updatedAt = new Date();
|
||
persistMissionSession(session, "awaiting_input");
|
||
missionInterviewStreamManager.broadcast(session.id, {
|
||
type: "question",
|
||
data: parsed.data,
|
||
});
|
||
} else if (parsed.type === "complete") {
|
||
session.summary = parsed.data;
|
||
session.currentQuestion = undefined;
|
||
session.error = undefined;
|
||
session.updatedAt = new Date();
|
||
persistMissionSession(session, "complete");
|
||
missionInterviewStreamManager.broadcast(session.id, {
|
||
type: "summary",
|
||
data: parsed.data,
|
||
});
|
||
missionInterviewStreamManager.broadcast(session.id, { type: "complete" });
|
||
}
|
||
},
|
||
);
|
||
} catch (err) {
|
||
// Timeout / user-stop already published an error state via the guard
|
||
// handlers. Don't double-broadcast a generic AbortError.
|
||
if (isAbortError(err)) {
|
||
return;
|
||
}
|
||
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
|
||
diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
|
||
setMissionSessionError(session, errorMessage);
|
||
}
|
||
}
|
||
|
||
// ── Session Management ──────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Create a new mission interview session with AI agent streaming.
|
||
* Returns sessionId immediately; client connects to SSE to receive events.
|
||
*/
|
||
export async function createMissionInterviewSession(
|
||
ip: string,
|
||
missionTitle: string,
|
||
rootDir: string,
|
||
store: TaskStore,
|
||
promptOverrides?: PromptOverrideMap,
|
||
modelProvider?: string,
|
||
modelId?: string,
|
||
projectId?: string | null,
|
||
): Promise<string> {
|
||
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 session: MissionInterviewSession = {
|
||
id: sessionId,
|
||
ip,
|
||
projectId: projectId ?? null,
|
||
missionId: "",
|
||
missionTitle,
|
||
history: [],
|
||
thinkingOutput: "",
|
||
lastGeneratedThinking: "",
|
||
modelProvider,
|
||
modelId,
|
||
store,
|
||
rootDir,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
sessions.set(sessionId, session);
|
||
persistMissionSession(session, "generating");
|
||
|
||
// Initialize AI agent in background
|
||
initializeAgent(session, rootDir, store, promptOverrides).catch((err) => {
|
||
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
|
||
persistMissionSession(session, "error", err.message || "Failed to initialize AI agent");
|
||
missionInterviewStreamManager.broadcast(sessionId, {
|
||
type: "error",
|
||
data: err.message || "Failed to initialize AI agent",
|
||
});
|
||
});
|
||
|
||
return sessionId;
|
||
}
|
||
|
||
/**
|
||
* Submit a response to the current question.
|
||
* Supports AI agent mode with streaming.
|
||
*/
|
||
export async function submitMissionInterviewResponse(
|
||
sessionId: string,
|
||
responses: Record<string, unknown>,
|
||
rootDir?: string,
|
||
store?: TaskStore,
|
||
promptOverrides?: PromptOverrideMap,
|
||
): Promise<MissionInterviewResponse> {
|
||
const session = getMissionInterviewSession(sessionId);
|
||
if (!session) {
|
||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||
}
|
||
|
||
if (store && !session.store) session.store = store;
|
||
if (rootDir && !session.rootDir) session.rootDir = rootDir;
|
||
|
||
if (!session.currentQuestion) {
|
||
throw new InvalidSessionStateError("No active question in session");
|
||
}
|
||
|
||
// Record the response
|
||
session.history.push({
|
||
question: session.currentQuestion,
|
||
response: responses,
|
||
thinkingOutput: session.lastGeneratedThinking || "",
|
||
});
|
||
session.error = undefined;
|
||
persistMissionSession(session, "generating");
|
||
|
||
if (!session.agent) {
|
||
const replayHistory = session.history.slice(0, -1);
|
||
await ensureMissionInterviewAgent(session, rootDir, store, replayHistory, promptOverrides);
|
||
}
|
||
|
||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||
await continueAgentConversation(session, message);
|
||
|
||
if (session.summary) {
|
||
return { type: "complete", data: session.summary };
|
||
}
|
||
if (session.currentQuestion) {
|
||
return { type: "question", data: session.currentQuestion };
|
||
}
|
||
// Fallback — should not happen with a working agent
|
||
return {
|
||
type: "question",
|
||
data: {
|
||
id: "q-fallback",
|
||
type: "text",
|
||
question: "Could you tell me more about what you want to build?",
|
||
description: "The AI is processing your response. Please provide more details.",
|
||
},
|
||
};
|
||
}
|
||
|
||
export async function retryMissionInterviewSession(
|
||
sessionId: string,
|
||
rootDir: string,
|
||
store?: TaskStore,
|
||
promptOverrides?: PromptOverrideMap,
|
||
): Promise<void> {
|
||
const session = getMissionInterviewSession(sessionId);
|
||
if (!session) {
|
||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||
}
|
||
|
||
if (store && !session.store) session.store = store;
|
||
if (rootDir && !session.rootDir) session.rootDir = rootDir;
|
||
|
||
const persisted = _aiSessionStore?.get(sessionId);
|
||
if (persisted && persisted.type !== "mission_interview") {
|
||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||
}
|
||
|
||
const inErrorState = persisted ? persisted.status === "error" : Boolean(session.error);
|
||
if (!inErrorState) {
|
||
throw new InvalidSessionStateError(`Mission interview session ${sessionId} is not in an error state`);
|
||
}
|
||
|
||
disposeMissionAgentForRetry(session);
|
||
|
||
session.error = undefined;
|
||
session.summary = undefined;
|
||
session.updatedAt = new Date();
|
||
persistMissionSession(session, "generating");
|
||
|
||
if (session.history.length === 0) {
|
||
await ensureMissionInterviewAgent(session, rootDir, store, [], promptOverrides);
|
||
await continueAgentConversation(
|
||
session,
|
||
`I want to plan a mission: "${session.missionTitle}". Interview me to understand what I need, then produce a structured plan.`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const replayHistory = session.history.slice(0, -1);
|
||
const lastEntry = session.history[session.history.length - 1];
|
||
|
||
await ensureMissionInterviewAgent(session, rootDir, store, replayHistory, promptOverrides);
|
||
const replayMessage = formatResponseForAgent(
|
||
lastEntry.question,
|
||
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
||
);
|
||
await continueAgentConversation(session, replayMessage);
|
||
}
|
||
|
||
export async function cancelMissionInterviewSession(sessionId: string): Promise<void> {
|
||
const removed = cleanupInMemoryMissionSession(sessionId);
|
||
if (!removed) {
|
||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||
}
|
||
|
||
unpersistMissionSession(sessionId);
|
||
}
|
||
|
||
export function listMissionInterviewDrafts(projectId?: string): MissionInterviewDraftSummary[] {
|
||
if (!_aiSessionStore) {
|
||
return [];
|
||
}
|
||
|
||
return _aiSessionStore
|
||
.listActive(projectId)
|
||
.filter((session) => {
|
||
if (session.type !== "mission_interview") {
|
||
return false;
|
||
}
|
||
if (!isMissionInterviewDraftStatus(session.status)) {
|
||
return false;
|
||
}
|
||
if (projectId) {
|
||
return session.projectId === projectId;
|
||
}
|
||
return session.projectId == null;
|
||
})
|
||
.map((session) => {
|
||
const row = _aiSessionStore?.get(session.id);
|
||
const conversation = row ? safeParseJson<unknown[]>(row.conversationHistory, []) : [];
|
||
return {
|
||
id: session.id,
|
||
title: session.title,
|
||
status: session.status as MissionInterviewDraftSummary["status"],
|
||
projectId: session.projectId,
|
||
createdAt: row?.createdAt ?? session.updatedAt,
|
||
updatedAt: session.updatedAt,
|
||
hasConversation: conversation.length > 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
export async function discardMissionInterviewSession(sessionId: string): Promise<{ removed: boolean }> {
|
||
try {
|
||
await cancelMissionInterviewSession(sessionId);
|
||
return { removed: true };
|
||
} catch (error) {
|
||
if (!(error instanceof SessionNotFoundError)) {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const removed = _aiSessionStore?.deleteByIdAndType(sessionId, "mission_interview") ?? false;
|
||
return { removed };
|
||
}
|
||
|
||
export function getMissionInterviewSession(sessionId: string): MissionInterviewSession | undefined {
|
||
const inMemory = sessions.get(sessionId);
|
||
if (inMemory) {
|
||
return inMemory;
|
||
}
|
||
|
||
if (!_aiSessionStore) {
|
||
return undefined;
|
||
}
|
||
|
||
const row = _aiSessionStore.get(sessionId);
|
||
if (!row || row.type !== "mission_interview") {
|
||
return undefined;
|
||
}
|
||
|
||
try {
|
||
const restored = buildMissionInterviewSessionFromRow(row);
|
||
sessions.set(restored.id, restored);
|
||
return restored;
|
||
} catch (error) {
|
||
diagnostics.errorFromException("Failed to restore session from SQLite", error, { sessionId, operation: "restore" });
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
export function getMissionInterviewSummary(sessionId: string): MissionPlanSummary | undefined {
|
||
return getMissionInterviewSession(sessionId)?.summary;
|
||
}
|
||
|
||
export function cleanupMissionInterviewSession(sessionId: string): void {
|
||
cleanupInMemoryMissionSession(sessionId);
|
||
unpersistMissionSession(sessionId);
|
||
}
|
||
|
||
/**
|
||
* Reset all mission interview state. Used for testing only.
|
||
*/
|
||
/**
|
||
* Test-only: register a stub session so SSE/buffer tests can drive the stream
|
||
* manager directly without spinning up the real AI agent (which is
|
||
* deliberately blocked in the vitest harness).
|
||
*/
|
||
export function __registerMissionInterviewSessionForTest(sessionId: string, missionTitle = "Test Mission"): void {
|
||
sessions.set(sessionId, {
|
||
id: sessionId,
|
||
ip: "127.0.0.1",
|
||
projectId: null,
|
||
missionId: "",
|
||
missionTitle,
|
||
history: [],
|
||
thinkingOutput: "",
|
||
lastGeneratedThinking: "",
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
});
|
||
}
|
||
|
||
export function __resetMissionInterviewState(): void {
|
||
for (const [id] of sessions) {
|
||
cleanupInMemoryMissionSession(id);
|
||
}
|
||
sessions.clear();
|
||
rateLimits.clear();
|
||
missionInterviewStreamManager.reset();
|
||
generationGuard.reset();
|
||
|
||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||
}
|
||
_aiSessionDeletedListener = undefined;
|
||
_aiSessionStore = undefined;
|
||
|
||
// Reset diagnostics sink to default
|
||
resetDiagnosticsSink();
|
||
}
|
||
|
||
// ── 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";
|
||
}
|
||
}
|