Merges FN-3139 planning comment infrastructure (comment inputs in mission/milestone-slice/planning modals, comment display in conversation history), FN-3119 Quick Chat FAB with slash-triggered skill menu and plugin integration, FN-3117 plugin dashboard views and chat session icons, and FN-3066 plugi Fusion-Task-Id: FN-3139
1353 lines
46 KiB
TypeScript
1353 lines
46 KiB
TypeScript
/**
|
|
* 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 } 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";
|
|
|
|
// 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<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. 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
|
|
|
|
## 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
|
|
|
|
## 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<string, TargetInterviewSession>();
|
|
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) => {
|
|
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<TargetInterviewHistoryEntry[]>(
|
|
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<TargetInterviewSummary | null>(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<string, Set<MilestoneSliceInterviewStreamCallback>>();
|
|
private readonly buffers = new Map<string, SessionEventBuffer>();
|
|
|
|
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, 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 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,
|
|
): Promise<AgentResult> {
|
|
await ensureEngineReady();
|
|
|
|
return createFnAgent({
|
|
cwd: rootDir,
|
|
systemPrompt: getSystemPrompt(session.targetType),
|
|
tools: "readonly",
|
|
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<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 ensureInterviewAgent(
|
|
session: TargetInterviewSession,
|
|
rootDir: string | undefined,
|
|
historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>,
|
|
): Promise<void> {
|
|
if (session.agent) {
|
|
return;
|
|
}
|
|
|
|
if (!rootDir) {
|
|
throw new TargetInvalidSessionStateError(
|
|
"AI agent not available for this session and cannot be resumed without project context"
|
|
);
|
|
}
|
|
|
|
session.agent = await createTargetInterviewAgent(session, rootDir);
|
|
|
|
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): Promise<void> {
|
|
try {
|
|
session.agent = await createTargetInterviewAgent(session, rootDir);
|
|
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<void> {
|
|
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
|
|
): 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: 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).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<string, unknown>,
|
|
rootDir?: string,
|
|
): Promise<TargetInterviewResponse> {
|
|
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, 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): Promise<void> {
|
|
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, []);
|
|
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, 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<void> {
|
|
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();
|
|
}
|