/** * Milestone and Slice Interview Session Management * * Manages AI-guided interview sessions for per-milestone and per-slice planning. * Uses an AI agent to conduct back-and-forth conversations that * produce refined scopes with verification criteria. * * Architecture mirrors mission-interview.ts but targets individual milestones/slices. * * Features: * - AI agent integration with real-time streaming via SSE * - Rate limiting per IP * - Session expiration and cleanup * - SSE streaming via MilestoneSliceInterviewStreamManager * - Unified session type for both milestone and slice interviews */ import type { PlanningQuestion, Milestone, Slice, MissionStore, InterviewState, SlicePlanState, TaskStore } from "@fusion/core"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js"; import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js"; import { extractJsonCandidate, repairJson, } from "./mission-interview.js"; import { createSessionDiagnostics, resetDiagnosticsSink, nonfatal, } from "./ai-session-diagnostics.js"; import { GenerationGuard, isAbortError } from "./ai-session-timeout.js"; // Re-export JSON parsing utilities from mission-interview for external consumers export { parseMissionAgentResponse, extractJsonCandidate, repairJson, } from "./mission-interview.js"; /** * Shared diagnostics helper for the milestone-slice-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("milestone-slice-interview"); /** * Parse a target interview response (milestone or slice) from the AI agent. * Validates the response structure and extracts the typed data. */ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse { 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 (_parseErr) { 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.` ); } } // Validate structure 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 typed as TargetInterviewResponse; } if (typed.type === "complete" && typed.data !== null && typeof typed.data === "object") { return typed as TargetInterviewResponse; } } 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."); } // Export the parse function for tests export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse }; 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; // eslint-disable-next-line @typescript-eslint/no-explicit-any const createFnAgent: any = engineCreateFnAgent; function ensureEngineReady(): Promise { 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. Bounds a stalled model stream or hung tool * call so the session cannot stay pinned in `generating` indefinitely. */ export const GENERATION_TIMEOUT_MS = 120_000; const generationGuard = new GenerationGuard(); /** Milestone interview system prompt */ export const MILESTONE_INTERVIEW_SYSTEM_PROMPT = `You are a milestone planning assistant for a project management system. Your job: help users refine the scope of a specific milestone, identify verification criteria, and break it into manageable slices. ## Milestone Context A milestone represents a major phase or deliverable within a larger mission. Each milestone should have: - Clear scope and boundaries - Verification criteria for completion - Logical slices that can be worked on independently ## Conversation Flow 1. Start by understanding the milestone's purpose within the mission context 2. Ask clarifying questions about scope, timeline, dependencies, priorities 3. Push back on vague objectives — ask for specifics 4. Challenge unrealistic scope — suggest phasing 5. Once you have enough information (typically 3-5 questions), produce the refined plan 6. Help identify slices if not already defined ## Question Types to Use - "text": Open-ended questions for detailed input - "single_select": When user must choose one option (e.g., priority, approach) - "multi_select": When multiple options can apply (e.g., features to include) - "confirm": Yes/No questions for quick decisions ## Guidelines - Focus on scope refinement — what should this milestone include/exclude? - Ask about dependencies on other milestones - Clarify verification criteria — how do we know this milestone is "done"? - Help break into slices if the milestone is large - Each slice should be independently shippable work - ALWAYS include verification criteria at every level: - Milestone: "verification" field — how to confirm this phase is complete - Slice: "verification" field — how to confirm this work unit is done ## 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 questions: {"type": "question", "data": {"id": "unique-id", "type": "text|single_select|multi_select|confirm", "question": "The question text", "description": "Helpful context", "options": [{"id": "opt1", "label": "Option 1", "description": "Details"}]}} For completion (when you have enough information): {"type": "complete", "data": {"title": "Refined milestone title", "description": "Detailed scope description", "planningNotes": "Key planning decisions and context", "verification": "How to confirm this milestone is complete", "slices": [{"title": "Slice title", "description": "What this work unit covers", "verification": "How to confirm this slice is done"}]}}`; /** Slice interview system prompt */ export const SLICE_INTERVIEW_SYSTEM_PROMPT = `You are a slice planning assistant for a project management system. Your job: help users refine the scope of a specific slice, identify verification criteria, and break it into features with acceptance criteria. ## Slice Context A slice represents a focused work unit within a milestone that can be activated and worked on independently. Each slice should have: - Clear scope and boundaries - Verification criteria for completion - Specific features with acceptance criteria ## Conversation Flow 1. Start by understanding the slice's purpose within its milestone 2. Ask clarifying questions about scope, technical approach, edge cases 3. Push back on vague objectives — ask for specifics 4. Challenge unrealistic scope — suggest prioritization 5. Once you have enough information (typically 3-5 questions), produce the refined plan 6. Help identify features with clear acceptance criteria ## Question Types to Use - "text": Open-ended questions for detailed input - "single_select": When user must choose one option (e.g., technical approach) - "multi_select": When multiple options can apply (e.g., edge cases to handle) - "confirm": Yes/No questions for quick decisions ## Guidelines - Focus on scope refinement — what should this slice include/exclude? - Ask about technical approach and implementation details - Clarify edge cases and error handling requirements - Ask about testing strategy - Help break into features with clear acceptance criteria - ALWAYS include verification criteria at every level: - Slice: "verification" field — how to confirm this work unit is done - Feature: "acceptanceCriteria" field — how to verify this specific deliverable ## 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 questions: {"type": "question", "data": {"id": "unique-id", "type": "text|single_select|multi_select|confirm", "question": "The question text", "description": "Helpful context", "options": [{"id": "opt1", "label": "Option 1", "description": "Details"}]}} For completion (when you have enough information): {"type": "complete", "data": {"title": "Refined slice title", "description": "Detailed scope description", "planningNotes": "Key planning decisions and technical approach", "verification": "How to confirm this slice is complete", "features": [{"title": "Feature title", "description": "What to build", "acceptanceCriteria": "How to verify this feature works"}]}}`; // ── Types ─────────────────────────────────────────────────────────────────── /** Target type for interview session */ export type TargetType = "milestone" | "slice"; /** A feature within a slice in the generated plan */ export interface SliceFeature { title: string; description?: string; acceptanceCriteria?: string; } /** A slice within a milestone in the generated plan */ export interface MilestoneSlice { title: string; description?: string; verification?: string; } /** The complete milestone interview summary produced by the interview */ export interface MilestoneInterviewSummary { title?: string; description?: string; planningNotes?: string; verification?: string; slices?: MilestoneSlice[]; } /** The complete slice interview summary produced by the interview */ export interface SliceInterviewSummary { title?: string; description?: string; planningNotes?: string; verification?: string; features?: SliceFeature[]; } /** Union type for interview summaries */ export type TargetInterviewSummary = MilestoneInterviewSummary | SliceInterviewSummary; /** Response from interview: either a question or a completed plan */ export type TargetInterviewResponse = | { type: "question"; data: PlanningQuestion } | { type: "complete"; data: TargetInterviewSummary }; /** SSE event types for milestone/slice interview streaming */ export type MilestoneSliceInterviewStreamEvent = | { type: "thinking"; data: string } | { type: "question"; data: PlanningQuestion } | { type: "summary"; data: TargetInterviewSummary } | { type: "error"; data: string } | { type: "complete" }; /** Callback function for streaming events */ export type MilestoneSliceInterviewStreamCallback = (event: MilestoneSliceInterviewStreamEvent, eventId?: number) => void; interface TargetInterviewHistoryEntry { question: PlanningQuestion; response: unknown; thinkingOutput?: string; } /** In-memory interview session for milestones and slices */ interface TargetInterviewSession { id: string; ip: string; targetType: TargetType; targetId: string; targetTitle: string; missionContext?: string; history: TargetInterviewHistoryEntry[]; currentQuestion?: PlanningQuestion; summary?: TargetInterviewSummary; /** Last terminal error for retry UX */ error?: string; agent?: AgentResult; thinkingOutput: string; /** Thinking output generated while producing currentQuestion */ lastGeneratedThinking: string; createdAt: Date; updatedAt: Date; } interface RateLimitEntry { count: number; firstRequestAt: Date; } // ── In-Memory Storage ─────────────────────────────────────────────────────── const sessions = new Map(); const rateLimits = new Map(); // ── AI Session Persistence ──────────────────────────────────────────────── let _aiSessionStore: AiSessionStore | undefined; let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined; function safeParseJson( 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) => { cleanupInMemorySession(sessionId); }; _aiSessionStore.on("ai_session:deleted", _aiSessionDeletedListener); } function cleanupInMemorySession(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; } milestoneSliceInterviewStreamManager.cleanupSession(sessionId); sessions.delete(sessionId); return true; } function setTargetSessionError(session: TargetInterviewSession, message: string): void { session.error = message; session.updatedAt = new Date(); persistSession(session, "error", message); milestoneSliceInterviewStreamManager.broadcast(session.id, { type: "error", data: message, }); } /** * Manually abort an in-flight milestone/slice interview generation. * Returns true if a generation was active and got aborted. */ export function stopMilestoneSliceInterviewGeneration(sessionId: string): boolean { return generationGuard.stop(sessionId); } function getSessionType(targetType: TargetType): "milestone_interview" | "slice_interview" { return targetType === "milestone" ? "milestone_interview" : "slice_interview"; } function persistSession(session: TargetInterviewSession, status: "generating" | "awaiting_input" | "complete" | "error", error?: string): void { if (!_aiSessionStore) return; const row: AiSessionRow = { id: session.id, type: getSessionType(session.targetType), status, title: session.targetTitle.slice(0, 120), inputPayload: JSON.stringify({ ip: session.ip, targetType: session.targetType, targetId: session.targetId, targetTitle: session.targetTitle, missionContext: session.missionContext, }), 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: null, createdAt: session.createdAt.toISOString(), updatedAt: new Date().toISOString(), lockedByTab: null, lockedAt: null, }; _aiSessionStore.upsert(row); } function persistThinking(sessionId: string, thinkingOutput: string): void { if (!_aiSessionStore) return; _aiSessionStore.updateThinking(sessionId, thinkingOutput); } function unpersistSession(sessionId: string): void { if (!_aiSessionStore) return; _aiSessionStore.delete(sessionId); } function buildSessionFromRow(row: AiSessionRow): TargetInterviewSession { const payload = safeParseJson<{ ip?: string; targetType?: TargetType; targetId?: string; targetTitle?: string; missionContext?: 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 ?? "", targetType: payload.targetType ?? "milestone", targetId: payload.targetId ?? "", targetTitle: payload.targetTitle ?? row.title, missionContext: payload.missionContext, history: safeParseJson( row.conversationHistory, [], { throwOnError: true, fieldName: "conversationHistory" }, ), currentQuestion: row.currentQuestion ? (safeParseJson(row.currentQuestion, null, { throwOnError: true, fieldName: "currentQuestion", }) ?? undefined) : undefined, summary: row.result ? (safeParseJson(row.result, null, { throwOnError: true, fieldName: "result", }) ?? undefined) : undefined, thinkingOutput: row.thinkingOutput, lastGeneratedThinking: row.thinkingOutput || "", error: row.error ?? undefined, createdAt, updatedAt, agent: undefined, }; } export function rehydrateFromStore(store: AiSessionStore): number { let rows: AiSessionRow[] = []; try { rows = store.listRecoverable().filter( (row) => row.type === "milestone_interview" || row.type === "slice_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 = buildSessionFromRow(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) { cleanupInMemorySession(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 MilestoneSliceInterviewStreamManager extends EventEmitter { private readonly sessions = new Map>(); private readonly buffers = new Map(); constructor(private readonly bufferSize = 100) { super(); } subscribe(sessionId: string, callback: MilestoneSliceInterviewStreamCallback): () => 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: MilestoneSliceInterviewStreamEvent): 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 milestoneSliceInterviewStreamManager = new MilestoneSliceInterviewStreamManager(); // ── 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); } // ── Response Formatting ────────────────────────────────────────────────────── /** * Format user response as a message for the AI agent. */ function formatResponseForAgent( question: PlanningQuestion, responses: Record ): 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 { if (response && typeof response === "object" && !Array.isArray(response)) { return response as Record; } return { [question.id]: response, }; } function disposeAgentForRetry(session: TargetInterviewSession): 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 ─────────────────────────────────────────────────── function getSystemPrompt(targetType: TargetType): string { return targetType === "milestone" ? MILESTONE_INTERVIEW_SYSTEM_PROMPT : SLICE_INTERVIEW_SYSTEM_PROMPT; } async function createTargetInterviewAgent( session: TargetInterviewSession, rootDir: string, store: TaskStore, ): Promise { await ensureEngineReady(); return createFnAgent({ cwd: rootDir, systemPrompt: getSystemPrompt(session.targetType), tools: "readonly", customTools: [...createPlanningBoardTools(store)], onThinking: (delta: string) => { session.thinkingOutput += delta; persistThinking(session.id, session.thinkingOutput); milestoneSliceInterviewStreamManager.broadcast(session.id, { type: "thinking", data: delta, }); }, onText: (delta: string) => { session.thinkingOutput += delta; }, }); } function formatInterviewHistory( 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) : 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 ensureInterviewAgent( session: TargetInterviewSession, rootDir: string | undefined, store: TaskStore | undefined, historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>, ): Promise { if (session.agent) { return; } if (!rootDir) { throw new TargetInvalidSessionStateError( "AI agent not available for this session and cannot be resumed without project context" ); } if (!store) { throw new TargetInvalidSessionStateError( "AI agent not available for this session and cannot be resumed without task store context", ); } session.agent = await createTargetInterviewAgent(session, rootDir, store); if (historyForReplay.length === 0) { return; } const historySummary = formatInterviewHistory(historyForReplay); if (!historySummary) { return; } await generationGuard.run( session.id, GENERATION_TIMEOUT_MS, { onTimeout: () => setTargetSessionError( session, "AI generation timed out while restoring context. You can retry or start a new session.", ), onUserStop: () => setTargetSessionError( 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"), ), ); } /** * Initialize the AI agent for a session and start the first turn. */ async function initializeAgent(session: TargetInterviewSession, rootDir: string, store: TaskStore): Promise { try { session.agent = await createTargetInterviewAgent(session, rootDir, store); session.updatedAt = new Date(); // Send initial message to get first question await continueAgentConversation( session, `I want to refine the scope for this ${session.targetType}: "${session.targetTitle}".` + (session.missionContext ? `\n\nMission context: ${session.missionContext}` : "") + ` Interview me to understand what you need, then produce a refined 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(); persistSession(session, "error", errorMessage); milestoneSliceInterviewStreamManager.broadcast(session.id, { type: "error", data: errorMessage, }); } } /** * Continue the AI conversation with a user message. * Includes bounded recovery: one retry on parse failure. */ async function continueAgentConversation(session: TargetInterviewSession, message: string): Promise { if (!session.agent) { throw new TargetInvalidSessionStateError("AI agent not initialized"); } try { await generationGuard.run( session.id, GENERATION_TIMEOUT_MS, { onTimeout: () => setTargetSessionError( session, "AI generation timed out. You can retry or start a new session.", ), onUserStop: () => setTargetSessionError( 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 using the target interview parser let parsed: TargetInterviewResponse | undefined; let lastError: Error | undefined; for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) { try { parsed = parseTargetInterviewResponseImpl(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":{"title":"...","description":"...","planningNotes":"...","verification":"..."}}' + ". 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" } ); setTargetSessionError(session, errorMsg); return; } if (parsed.type === "question") { session.currentQuestion = parsed.data; session.error = undefined; session.lastGeneratedThinking = session.thinkingOutput; session.updatedAt = new Date(); persistSession(session, "awaiting_input"); milestoneSliceInterviewStreamManager.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(); persistSession(session, "complete"); milestoneSliceInterviewStreamManager.broadcast(session.id, { type: "summary", data: parsed.data, }); milestoneSliceInterviewStreamManager.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" }); setTargetSessionError(session, errorMessage); } } // ── Session Management ────────────────────────────────────────────────────── /** * Create a new milestone/slice interview session with AI agent streaming. * Returns sessionId immediately; client connects to SSE to receive events. */ export async function createTargetInterviewSession( ip: string, targetType: TargetType, targetId: string, targetTitle: string, missionContext: string | undefined, rootDir: string, store: TaskStore, ): Promise { 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: TargetInterviewSession = { id: sessionId, ip, targetType, targetId, targetTitle, missionContext, history: [], thinkingOutput: "", lastGeneratedThinking: "", createdAt: new Date(), updatedAt: new Date(), }; sessions.set(sessionId, session); persistSession(session, "generating"); // Initialize AI agent in background initializeAgent(session, rootDir, store).catch((err) => { diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" }); persistSession(session, "error", err.message || "Failed to initialize AI agent"); milestoneSliceInterviewStreamManager.broadcast(sessionId, { type: "error", data: err.message || "Failed to initialize AI agent", }); }); return sessionId; } /** * Submit a response to the current question. */ export async function submitTargetInterviewResponse( sessionId: string, responses: Record, rootDir?: string, store?: TaskStore, ): Promise { const session = getTargetInterviewSession(sessionId); if (!session) { throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`); } if (!session.currentQuestion) { throw new TargetInvalidSessionStateError("No active question in session"); } // Record the response session.history.push({ question: session.currentQuestion, response: responses, thinkingOutput: session.lastGeneratedThinking || "", }); session.error = undefined; persistSession(session, "generating"); if (!session.agent) { const replayHistory = session.history.slice(0, -1); await ensureInterviewAgent(session, rootDir, store, replayHistory); } 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 accomplish?", description: "The AI is processing your response. Please provide more details.", }, }; } /** * Retry a failed interview session. */ export async function retryTargetInterviewSession(sessionId: string, rootDir: string, store?: TaskStore): Promise { const session = getTargetInterviewSession(sessionId); if (!session) { throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`); } const persisted = _aiSessionStore?.get(sessionId); if (persisted) { const sessionType = getSessionType(session.targetType); if (persisted.type !== sessionType) { throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`); } } const inErrorState = persisted ? persisted.status === "error" : Boolean(session.error); if (!inErrorState) { throw new TargetInvalidSessionStateError(`Interview session ${sessionId} is not in an error state`); } disposeAgentForRetry(session); session.error = undefined; session.summary = undefined; session.updatedAt = new Date(); persistSession(session, "generating"); if (session.history.length === 0) { await ensureInterviewAgent(session, rootDir, store, []); await continueAgentConversation( session, `I want to refine the scope for this ${session.targetType}: "${session.targetTitle}".` + (session.missionContext ? `\n\nMission context: ${session.missionContext}` : "") + ` Interview me to understand what you need, then produce a refined plan.`, ); return; } const replayHistory = session.history.slice(0, -1); const lastEntry = session.history[session.history.length - 1]; await ensureInterviewAgent(session, rootDir, store, replayHistory); const replayMessage = formatResponseForAgent( lastEntry.question, coerceResponseRecord(lastEntry.question, lastEntry.response), ); await continueAgentConversation(session, replayMessage); } /** * Cancel and cleanup an interview session. */ export async function cancelTargetInterviewSession(sessionId: string): Promise { const removed = cleanupInMemorySession(sessionId); if (!removed) { throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`); } unpersistSession(sessionId); } /** * Get session by ID (in-memory or from SQLite). */ export function getTargetInterviewSession(sessionId: string): TargetInterviewSession | undefined { const inMemory = sessions.get(sessionId); if (inMemory) { return inMemory; } if (!_aiSessionStore) { return undefined; } const row = _aiSessionStore.get(sessionId); if (!row || (row.type !== "milestone_interview" && row.type !== "slice_interview")) { return undefined; } try { const restored = buildSessionFromRow(row); sessions.set(restored.id, restored); return restored; } catch (error) { diagnostics.errorFromException("Failed to restore session from SQLite", error, { sessionId, operation: "restore" }); return undefined; } } /** * Get the summary from a completed session. */ export function getTargetInterviewSummary(sessionId: string): TargetInterviewSummary | undefined { return getTargetInterviewSession(sessionId)?.summary; } /** * Cleanup both in-memory and SQLite. */ export function cleanupTargetInterviewSession(sessionId: string): void { cleanupInMemorySession(sessionId); unpersistSession(sessionId); } // ── Apply & Skip ─────────────────────────────────────────────────────────── /** * Apply the interview summary to the target (milestone or slice). */ export function applyTargetInterview( sessionId: string, missionStore: MissionStore ): Milestone | Slice { const session = getTargetInterviewSession(sessionId); if (!session) { throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`); } const summary = session.summary; if (!summary) { throw new TargetInvalidSessionStateError("Interview session has no summary to apply"); } let result: Milestone | Slice; if (session.targetType === "milestone") { const milestone = missionStore.getMilestone(session.targetId); if (!milestone) { throw new TargetSessionNotFoundError(`Milestone ${session.targetId} not found`); } result = missionStore.updateMilestone(session.targetId, { description: summary.description, planningNotes: summary.planningNotes, verification: summary.verification, interviewState: "completed" as InterviewState, }); } else { const slice = missionStore.getSlice(session.targetId); if (!slice) { throw new TargetSessionNotFoundError(`Slice ${session.targetId} not found`); } result = missionStore.updateSlice(session.targetId, { description: summary.description, planningNotes: summary.planningNotes, verification: summary.verification, planState: "planned" as SlicePlanState, }); } // Cleanup the interview session cleanupTargetInterviewSession(sessionId); return result; } /** * Skip the interview and apply mission-level context directly. */ export function skipTargetInterview( targetType: TargetType, targetId: string, missionStore: MissionStore ): Milestone | Slice { let result: Milestone | Slice; if (targetType === "milestone") { const milestone = missionStore.getMilestone(targetId); if (!milestone) { throw new TargetSessionNotFoundError(`Milestone ${targetId} not found`); } // Get mission context for the skip message const mission = missionStore.getMission(milestone.missionId); const contextMessage = mission ? `Planned using mission-level context (no per-milestone interview). Mission: "${mission.title}". ${mission.description || ""}` : "Planned using mission-level context (no per-milestone interview)"; result = missionStore.updateMilestone(targetId, { planningNotes: contextMessage, interviewState: "completed" as InterviewState, }); } else { const slice = missionStore.getSlice(targetId); if (!slice) { throw new TargetSessionNotFoundError(`Slice ${targetId} not found`); } // Get mission context for the skip message const milestone = missionStore.getMilestone(slice.milestoneId); const milestoneTitle = milestone?.title; const mission = milestone ? missionStore.getMission(milestone.missionId) : undefined; const contextMessage = mission ? `Planned using mission-level context (no per-slice interview). Mission: "${mission.title}". Milestone: "${milestoneTitle}". ${mission.description || ""}` : milestoneTitle ? `Planned using mission-level context (no per-slice interview). Milestone: "${milestoneTitle}".` : "Planned using mission-level context (no per-slice interview)"; result = missionStore.updateSlice(targetId, { planningNotes: contextMessage, planState: "planned" as SlicePlanState, }); } return result; } // ── Custom Errors ─────────────────────────────────────────────────────────── export class RateLimitError extends Error { constructor(message: string) { super(message); this.name = "RateLimitError"; } } export class TargetSessionNotFoundError extends Error { constructor(message: string) { super(message); this.name = "TargetSessionNotFoundError"; } } export class TargetInvalidSessionStateError extends Error { constructor(message: string) { super(message); this.name = "TargetInvalidSessionStateError"; } } /** * Reset all milestone/slice interview state. Used for testing only. */ export function __resetMilestoneSliceInterviewState(): void { for (const [id] of sessions) { cleanupInMemorySession(id); } sessions.clear(); rateLimits.clear(); milestoneSliceInterviewStreamManager.reset(); generationGuard.reset(); if (_aiSessionStore && _aiSessionDeletedListener) { _aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener); } _aiSessionDeletedListener = undefined; _aiSessionStore = undefined; // Reset diagnostics sink to default resetDiagnosticsSink(); }