feat: add AI-driven mission interview system (Factory.ai-style planning)
Add a conversational AI interview flow to the missions screen that interviews users to break down missions into milestones, slices, and features with verification criteria at every level. Backend: Replace stubbed interview logic with real AI agent integration using createKbAgent, SSE streaming, and robust JSON parsing. Wire up all 5 placeholder mission interview route endpoints (start, respond, cancel, stream, create-mission). Frontend: New MissionInterviewModal component with conversational UI, thinking output display, question renderers, and hierarchical plan review/edit. Integrated into MissionManager with "Plan with AI" button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2428,3 +2428,168 @@ export function unlinkFeatureFromTask(featureId: string, projectId?: string): Pr
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mission Interview API ─────────────────────────────────────────────────
|
||||
|
||||
/** Mission plan types returned by the interview AI */
|
||||
export interface MissionPlanFeature {
|
||||
title: string;
|
||||
description?: string;
|
||||
acceptanceCriteria?: string;
|
||||
}
|
||||
|
||||
export interface MissionPlanSlice {
|
||||
title: string;
|
||||
description?: string;
|
||||
verification?: string;
|
||||
features: MissionPlanFeature[];
|
||||
}
|
||||
|
||||
export interface MissionPlanMilestone {
|
||||
title: string;
|
||||
description?: string;
|
||||
verification?: string;
|
||||
slices: MissionPlanSlice[];
|
||||
}
|
||||
|
||||
export interface MissionPlanSummary {
|
||||
missionTitle?: string;
|
||||
missionDescription?: string;
|
||||
milestones: MissionPlanMilestone[];
|
||||
}
|
||||
|
||||
export type MissionInterviewResponse =
|
||||
| { type: "question"; data: PlanningQuestion }
|
||||
| { type: "complete"; data: MissionPlanSummary };
|
||||
|
||||
/** Start a mission interview session with AI streaming */
|
||||
export function startMissionInterview(missionTitle: string, projectId?: string): Promise<{ sessionId: string }> {
|
||||
return api<{ sessionId: string }>(withProjectId("/missions/interview/start", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ missionTitle }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Submit a response to the current interview question */
|
||||
export function respondToMissionInterview(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>,
|
||||
projectId?: string
|
||||
): Promise<MissionInterviewResponse> {
|
||||
return api<MissionInterviewResponse>(withProjectId("/missions/interview/respond", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId, responses }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel an active mission interview session */
|
||||
export function cancelMissionInterview(sessionId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/missions/interview/cancel", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Create mission from completed interview */
|
||||
export function createMissionFromInterview(
|
||||
sessionId: string,
|
||||
summary?: MissionPlanSummary,
|
||||
projectId?: string
|
||||
): Promise<MissionWithHierarchy> {
|
||||
return api<MissionWithHierarchy>(withProjectId("/missions/interview/create-mission", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId, summary }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Connect to mission interview SSE stream and handle events */
|
||||
export function connectMissionInterviewStream(
|
||||
sessionId: string,
|
||||
projectId: string | undefined,
|
||||
handlers: {
|
||||
onThinking?: (data: string) => void;
|
||||
onQuestion?: (data: PlanningQuestion) => void;
|
||||
onSummary?: (data: MissionPlanSummary) => void;
|
||||
onError?: (data: string) => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
): { close: () => void; isConnected: () => boolean } {
|
||||
const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId));
|
||||
const eventSource = new EventSource(url);
|
||||
let isClosed = false;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
isClosed = false;
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
if (event.data.startsWith(":")) return;
|
||||
};
|
||||
|
||||
eventSource.addEventListener("thinking", (event: Event) => {
|
||||
try {
|
||||
const messageEvent = event as MessageEvent;
|
||||
const data = JSON.parse(messageEvent.data);
|
||||
handlers.onThinking?.(data);
|
||||
} catch {
|
||||
const messageEvent = event as MessageEvent;
|
||||
handlers.onThinking?.(messageEvent.data);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("question", (event: Event) => {
|
||||
try {
|
||||
const messageEvent = event as MessageEvent;
|
||||
const data = JSON.parse(messageEvent.data) as PlanningQuestion;
|
||||
handlers.onQuestion?.(data);
|
||||
} catch (err) {
|
||||
console.error("[mission-interview] Failed to parse question event:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("summary", (event: Event) => {
|
||||
try {
|
||||
const messageEvent = event as MessageEvent;
|
||||
const data = JSON.parse(messageEvent.data) as MissionPlanSummary;
|
||||
handlers.onSummary?.(data);
|
||||
} catch (err) {
|
||||
console.error("[mission-interview] Failed to parse summary event:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("error", (event: Event) => {
|
||||
try {
|
||||
const messageEvent = event as MessageEvent;
|
||||
const data = JSON.parse(messageEvent.data);
|
||||
handlers.onError?.(data.message || data);
|
||||
} catch {
|
||||
const messageEvent = event as MessageEvent;
|
||||
handlers.onError?.(messageEvent.data || "Stream error");
|
||||
}
|
||||
close();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("complete", () => {
|
||||
handlers.onComplete?.();
|
||||
close();
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
if (!isClosed) {
|
||||
handlers.onError?.("Connection lost");
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
function close() {
|
||||
if (!isClosed) {
|
||||
isClosed = true;
|
||||
eventSource.close();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
close,
|
||||
isConnected: () => !isClosed && eventSource.readyState === EventSource.OPEN,
|
||||
};
|
||||
}
|
||||
|
||||
989
packages/dashboard/app/components/MissionInterviewModal.tsx
Normal file
989
packages/dashboard/app/components/MissionInterviewModal.tsx
Normal file
@@ -0,0 +1,989 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import {
|
||||
startMissionInterview,
|
||||
respondToMissionInterview,
|
||||
cancelMissionInterview,
|
||||
createMissionFromInterview,
|
||||
connectMissionInterviewStream,
|
||||
type MissionPlanSummary,
|
||||
type MissionPlanMilestone,
|
||||
type MissionPlanSlice,
|
||||
type MissionPlanFeature,
|
||||
type MissionWithHierarchy,
|
||||
} from "../api";
|
||||
import {
|
||||
Target,
|
||||
X,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Layers,
|
||||
Package,
|
||||
Box,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface MissionInterviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onMissionCreated: (mission: MissionWithHierarchy) => void;
|
||||
projectId?: string;
|
||||
initialGoal?: string;
|
||||
}
|
||||
|
||||
interface QuestionResponse {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ViewState =
|
||||
| { type: "initial" }
|
||||
| { type: "loading" }
|
||||
| { type: "question"; sessionId: string; question: PlanningQuestion }
|
||||
| { type: "summary"; sessionId: string; summary: MissionPlanSummary };
|
||||
|
||||
const EXAMPLE_MISSIONS = [
|
||||
"Build a real-time collaborative document editor",
|
||||
"Create a customer onboarding flow with email verification",
|
||||
"Add a reporting dashboard with charts and CSV export",
|
||||
"Implement a plugin system with marketplace",
|
||||
];
|
||||
|
||||
export function MissionInterviewModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onMissionCreated,
|
||||
projectId,
|
||||
initialGoal: initialGoalProp,
|
||||
}: MissionInterviewModalProps) {
|
||||
const [missionGoal, setMissionGoal] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
|
||||
const [editedSummary, setEditedSummary] = useState<MissionPlanSummary | null>(null);
|
||||
const [hasProgress, setHasProgress] = useState(false);
|
||||
const hasAutoStartedRef = useRef(false);
|
||||
const [streamingOutput, setStreamingOutput] = useState("");
|
||||
const [showThinking, setShowThinking] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const handleStartInterview = useCallback(
|
||||
async (goalOverride?: string) => {
|
||||
const goal = goalOverride ?? missionGoal;
|
||||
if (!goal.trim()) return;
|
||||
|
||||
setError(null);
|
||||
setStreamingOutput("");
|
||||
setView({ type: "loading" });
|
||||
|
||||
try {
|
||||
const { sessionId } = await startMissionInterview(goal.trim(), projectId);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
const connection = connectMissionInterviewStream(sessionId, projectId, {
|
||||
onThinking: (data) => {
|
||||
setStreamingOutput((prev) => prev + data);
|
||||
},
|
||||
onQuestion: (question) => {
|
||||
setView({ type: "question", sessionId, question });
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setView({ type: "summary", sessionId, summary });
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
},
|
||||
onError: (message) => {
|
||||
setError(message);
|
||||
setView({ type: "initial" });
|
||||
setStreamingOutput("");
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onComplete: () => {
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
});
|
||||
|
||||
streamConnectionRef.current = connection;
|
||||
setResponseHistory([]);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to start interview session");
|
||||
setView({ type: "initial" });
|
||||
currentSessionIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[missionGoal, projectId]
|
||||
);
|
||||
|
||||
// Focus textarea when opening
|
||||
useEffect(() => {
|
||||
if (isOpen && view.type === "initial") {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [isOpen, view.type]);
|
||||
|
||||
// Auto-start when initialGoal prop is provided
|
||||
useEffect(() => {
|
||||
if (isOpen && initialGoalProp && !hasAutoStartedRef.current && view.type === "initial") {
|
||||
setMissionGoal(initialGoalProp);
|
||||
const timer = setTimeout(() => {
|
||||
hasAutoStartedRef.current = true;
|
||||
handleStartInterview(initialGoalProp);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, initialGoalProp, view.type, handleStartInterview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
hasAutoStartedRef.current = false;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Cleanup stream on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Unload protection
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
streamConnectionRef.current?.close();
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isOpen, view]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (hasProgress) {
|
||||
if (!confirm("Are you sure you want to close? Your interview progress will be lost.")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
try {
|
||||
await cancelMissionInterview(view.sessionId, projectId);
|
||||
} catch {
|
||||
// Ignore errors on cancel
|
||||
}
|
||||
}
|
||||
|
||||
setMissionGoal("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
setIsCreating(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
}, [hasProgress, view, onClose, projectId]);
|
||||
|
||||
// Escape key handler
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (hasProgress) {
|
||||
if (confirm("Are you sure you want to close? Your interview progress will be lost.")) {
|
||||
handleCancel();
|
||||
}
|
||||
} else {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, hasProgress, handleCancel]);
|
||||
|
||||
const handleSubmitResponse = useCallback(
|
||||
async (responses: QuestionResponse) => {
|
||||
if (view.type !== "question") return;
|
||||
|
||||
const { sessionId } = view;
|
||||
setError(null);
|
||||
setView({ type: "loading" });
|
||||
setStreamingOutput("");
|
||||
|
||||
try {
|
||||
await respondToMissionInterview(sessionId, responses, projectId);
|
||||
setResponseHistory((prev) => [...prev, responses]);
|
||||
setHasProgress(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to submit response");
|
||||
setView({ type: "question", sessionId, question: view.question });
|
||||
}
|
||||
},
|
||||
[view, projectId]
|
||||
);
|
||||
|
||||
const handleApprovePlan = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
|
||||
setError(null);
|
||||
setIsCreating(true);
|
||||
|
||||
try {
|
||||
const mission = await createMissionFromInterview(view.sessionId, editedSummary || undefined, projectId);
|
||||
onMissionCreated(mission);
|
||||
// Reset state without confirmation
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
setMissionGoal("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
setIsCreating(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to create mission");
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [view, editedSummary, onMissionCreated, onClose, projectId]);
|
||||
|
||||
const getProgress = () => {
|
||||
if (view.type === "question") {
|
||||
return Math.min(responseHistory.length + 1, 6);
|
||||
}
|
||||
return 6;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && handleCancel()}>
|
||||
<div className="modal modal-lg planning-modal">
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<Target size={20} style={{ color: "var(--triage)" }} />
|
||||
<h3>Plan Mission with AI</h3>
|
||||
</div>
|
||||
<button className="modal-close" onClick={handleCancel} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="planning-modal-body">
|
||||
{error && <div className="form-error planning-error">{error}</div>}
|
||||
|
||||
{view.type === "initial" && (
|
||||
<div className="planning-initial">
|
||||
<div className="planning-view-scroll">
|
||||
<div className="planning-intro">
|
||||
<Sparkles size={32} style={{ color: "var(--triage)", marginBottom: "12px" }} />
|
||||
<h4>Transform your vision into a structured mission</h4>
|
||||
<p className="text-muted">
|
||||
Describe what you want to build. The AI will interview you to understand scope,
|
||||
constraints, and requirements, then produce a structured plan with milestones,
|
||||
slices, and features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="mission-goal">What do you want to build?</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="mission-goal"
|
||||
rows={4}
|
||||
className="planning-textarea"
|
||||
placeholder="e.g., Build a real-time collaborative document editor with presence, comments, and version history..."
|
||||
value={missionGoal}
|
||||
onChange={(e) => setMissionGoal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && missionGoal.trim()) {
|
||||
e.preventDefault();
|
||||
handleStartInterview();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="planning-examples">
|
||||
<span className="planning-examples-label">Try an example:</span>
|
||||
<div className="planning-example-chips">
|
||||
{EXAMPLE_MISSIONS.map((mission, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="planning-example-chip"
|
||||
onClick={() => setMissionGoal(mission)}
|
||||
>
|
||||
{mission.length > 45 ? mission.slice(0, 45) + "..." : mission}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-view-footer">
|
||||
<button
|
||||
className="btn btn-primary planning-start-btn"
|
||||
onClick={() => handleStartInterview()}
|
||||
disabled={!missionGoal.trim()}
|
||||
>
|
||||
<Target size={16} style={{ marginRight: "8px" }} />
|
||||
Start Interview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "loading" && (
|
||||
<div className="planning-loading">
|
||||
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
|
||||
<p>{streamingOutput ? "AI is thinking..." : "Preparing next question..."}</p>
|
||||
<div className="planning-thinking-container">
|
||||
<button
|
||||
className="planning-thinking-toggle"
|
||||
onClick={() => setShowThinking(!showThinking)}
|
||||
type="button"
|
||||
>
|
||||
{showThinking ? "Hide thinking" : "Show thinking"}
|
||||
</button>
|
||||
{showThinking && streamingOutput && (
|
||||
<div className="planning-thinking-output">
|
||||
<pre>{streamingOutput}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "question" && (
|
||||
<InterviewQuestionForm
|
||||
question={view.question}
|
||||
progress={getProgress()}
|
||||
onSubmit={handleSubmitResponse}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view.type === "summary" && editedSummary && (
|
||||
<MissionPlanReview
|
||||
summary={editedSummary}
|
||||
onSummaryChange={setEditedSummary}
|
||||
onApprove={handleApprovePlan}
|
||||
onStartOver={() => {
|
||||
setView({ type: "initial" });
|
||||
setHasProgress(false);
|
||||
setEditedSummary(null);
|
||||
setResponseHistory([]);
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
}}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Question Form (reused from PlanningModeModal pattern) ────────────────
|
||||
|
||||
interface InterviewQuestionFormProps {
|
||||
question: PlanningQuestion;
|
||||
progress: number;
|
||||
onSubmit: (responses: QuestionResponse) => void;
|
||||
}
|
||||
|
||||
function InterviewQuestionForm({ question, progress, onSubmit }: InterviewQuestionFormProps) {
|
||||
const [response, setResponse] = useState<QuestionResponse>({});
|
||||
const [textValue, setTextValue] = useState("");
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (question.type === "text") {
|
||||
onSubmit({ [question.id]: textValue });
|
||||
} else if (question.type === "confirm") {
|
||||
onSubmit({ [question.id]: response[question.id] === true });
|
||||
} else {
|
||||
onSubmit(response);
|
||||
}
|
||||
}, [question, response, textValue, onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
setResponse({});
|
||||
setTextValue("");
|
||||
}, [question.id]);
|
||||
|
||||
const isValid = () => {
|
||||
switch (question.type) {
|
||||
case "text":
|
||||
return textValue.trim().length > 0;
|
||||
case "single_select":
|
||||
return response[question.id] !== undefined;
|
||||
case "multi_select":
|
||||
return Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0;
|
||||
case "confirm":
|
||||
return response[question.id] !== undefined;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="planning-question-form">
|
||||
<div className="planning-view-scroll planning-question-scroll">
|
||||
<div className="planning-question-panel">
|
||||
<div className="planning-progress">
|
||||
<div className="planning-progress-bar">
|
||||
{[1, 2, 3, 4, 5, 6].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`planning-progress-step ${step <= progress ? "active" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="planning-progress-text">Question {progress} of ~6</span>
|
||||
</div>
|
||||
|
||||
<div className="planning-question-content">
|
||||
<h4 className="planning-question-text">{question.question}</h4>
|
||||
{question.description && (
|
||||
<p className="planning-question-desc">{question.description}</p>
|
||||
)}
|
||||
|
||||
<div className="planning-options">
|
||||
{question.type === "text" && (
|
||||
<textarea
|
||||
className="planning-textarea"
|
||||
rows={4}
|
||||
placeholder="Type your answer here..."
|
||||
value={textValue}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{question.type === "single_select" && question.options && (
|
||||
<div className="planning-radio-group" role="radiogroup">
|
||||
{question.options.map((option) => (
|
||||
<label key={option.id} className="planning-option planning-option--radio">
|
||||
<input
|
||||
type="radio"
|
||||
name={question.id}
|
||||
value={option.id}
|
||||
checked={response[question.id] === option.id}
|
||||
onChange={() => setResponse({ [question.id]: option.id })}
|
||||
/>
|
||||
<div className="planning-option-content">
|
||||
<span className="planning-option-label">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="planning-option-desc">{option.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === "multi_select" && question.options && (
|
||||
<div className="planning-checkbox-group">
|
||||
{question.options.map((option) => {
|
||||
const selected = (response[question.id] as string[]) || [];
|
||||
return (
|
||||
<label key={option.id} className="planning-option planning-option--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={option.id}
|
||||
checked={selected.includes(option.id)}
|
||||
onChange={(e) => {
|
||||
const newSelected = e.target.checked
|
||||
? [...selected, option.id]
|
||||
: selected.filter((id) => id !== option.id);
|
||||
setResponse({ [question.id]: newSelected });
|
||||
}}
|
||||
/>
|
||||
<div className="planning-option-content">
|
||||
<span className="planning-option-label">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="planning-option-desc">{option.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === "confirm" && (
|
||||
<div className="planning-confirm-group">
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === true ? "selected" : ""}`}
|
||||
onClick={() => setResponse({ [question.id]: true })}
|
||||
>
|
||||
<CheckCircle size={18} />
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === false ? "selected" : ""}`}
|
||||
onClick={() => setResponse({ [question.id]: false })}
|
||||
>
|
||||
<X size={18} />
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-actions">
|
||||
<button
|
||||
className="btn btn-primary planning-actions-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid()}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight size={16} style={{ marginLeft: "4px" }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mission Plan Review (hierarchical summary view) ──────────────────────
|
||||
|
||||
interface MissionPlanReviewProps {
|
||||
summary: MissionPlanSummary;
|
||||
onSummaryChange: (summary: MissionPlanSummary) => void;
|
||||
onApprove: () => void;
|
||||
onStartOver: () => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
function MissionPlanReview({
|
||||
summary,
|
||||
onSummaryChange,
|
||||
onApprove,
|
||||
onStartOver,
|
||||
isCreating,
|
||||
}: MissionPlanReviewProps) {
|
||||
const [expandedMilestones, setExpandedMilestones] = useState<Set<number>>(
|
||||
() => new Set(summary.milestones.map((_, i) => i))
|
||||
);
|
||||
const [expandedSlices, setExpandedSlices] = useState<Set<string>>(
|
||||
() => {
|
||||
const set = new Set<string>();
|
||||
summary.milestones.forEach((ms, mi) => {
|
||||
ms.slices.forEach((_, si) => set.add(`${mi}-${si}`));
|
||||
});
|
||||
return set;
|
||||
}
|
||||
);
|
||||
|
||||
const toggleMilestone = (index: number) => {
|
||||
setExpandedMilestones((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSlice = (key: string) => {
|
||||
setExpandedSlices((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateMilestone = (index: number, updates: Partial<MissionPlanMilestone>) => {
|
||||
const milestones = [...summary.milestones];
|
||||
milestones[index] = { ...milestones[index], ...updates };
|
||||
onSummaryChange({ ...summary, milestones });
|
||||
};
|
||||
|
||||
const updateSlice = (mi: number, si: number, updates: Partial<MissionPlanSlice>) => {
|
||||
const milestones = [...summary.milestones];
|
||||
const slices = [...milestones[mi].slices];
|
||||
slices[si] = { ...slices[si], ...updates };
|
||||
milestones[mi] = { ...milestones[mi], slices };
|
||||
onSummaryChange({ ...summary, milestones });
|
||||
};
|
||||
|
||||
const updateFeature = (mi: number, si: number, fi: number, updates: Partial<MissionPlanFeature>) => {
|
||||
const milestones = [...summary.milestones];
|
||||
const slices = [...milestones[mi].slices];
|
||||
const features = [...slices[si].features];
|
||||
features[fi] = { ...features[fi], ...updates };
|
||||
slices[si] = { ...slices[si], features };
|
||||
milestones[mi] = { ...milestones[mi], slices };
|
||||
onSummaryChange({ ...summary, milestones });
|
||||
};
|
||||
|
||||
const removeMilestone = (index: number) => {
|
||||
const milestones = summary.milestones.filter((_, i) => i !== index);
|
||||
onSummaryChange({ ...summary, milestones });
|
||||
};
|
||||
|
||||
const removeSlice = (mi: number, si: number) => {
|
||||
const milestones = [...summary.milestones];
|
||||
milestones[mi] = {
|
||||
...milestones[mi],
|
||||
slices: milestones[mi].slices.filter((_, i) => i !== si),
|
||||
};
|
||||
onSummaryChange({ ...summary, milestones });
|
||||
};
|
||||
|
||||
const removeFeature = (mi: number, si: number, fi: number) => {
|
||||
const milestones = [...summary.milestones];
|
||||
const slices = [...milestones[mi].slices];
|
||||
slices[si] = {
|
||||
...slices[si],
|
||||
features: slices[si].features.filter((_, i) => i !== fi),
|
||||
};
|
||||
milestones[mi] = { ...milestones[mi], slices };
|
||||
onSummaryChange({ ...summary, milestones });
|
||||
};
|
||||
|
||||
const addFeature = (mi: number, si: number) => {
|
||||
const milestones = [...summary.milestones];
|
||||
const slices = [...milestones[mi].slices];
|
||||
slices[si] = {
|
||||
...slices[si],
|
||||
features: [...slices[si].features, { title: "New feature", description: "" }],
|
||||
};
|
||||
milestones[mi] = { ...milestones[mi], slices };
|
||||
onSummaryChange({ ...summary, milestones });
|
||||
};
|
||||
|
||||
const totalFeatures = summary.milestones.reduce(
|
||||
(acc, ms) => acc + ms.slices.reduce((a, sl) => a + sl.features.length, 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="planning-summary">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
<div className="planning-summary-header">
|
||||
<CheckCircle size={24} style={{ color: "var(--color-success)" }} />
|
||||
<h4>Mission Plan Ready</h4>
|
||||
<p className="text-muted">
|
||||
{summary.milestones.length} milestones, {totalFeatures} features. Review and edit before approving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="planning-summary-form">
|
||||
{/* Mission title & description */}
|
||||
<div className="form-group">
|
||||
<label>Mission Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={summary.missionTitle || ""}
|
||||
onChange={(e) => onSummaryChange({ ...summary, missionTitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Mission Description</label>
|
||||
<textarea
|
||||
className="planning-textarea"
|
||||
rows={3}
|
||||
value={summary.missionDescription || ""}
|
||||
onChange={(e) => onSummaryChange({ ...summary, missionDescription: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Milestones hierarchy */}
|
||||
<div className="form-group">
|
||||
<label>Roadmap</label>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{summary.milestones.map((milestone, mi) => (
|
||||
<div
|
||||
key={mi}
|
||||
style={{
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Milestone header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => toggleMilestone(mi)}
|
||||
>
|
||||
{expandedMilestones.has(mi) ? (
|
||||
<ChevronDown size={16} style={{ color: "var(--text-secondary)", flexShrink: 0 }} />
|
||||
) : (
|
||||
<ChevronRight size={16} style={{ color: "var(--text-secondary)", flexShrink: 0 }} />
|
||||
)}
|
||||
<Layers size={16} style={{ color: "#eab308", flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ flex: 1, padding: "4px 8px", fontSize: "13px", fontWeight: 600 }}
|
||||
value={milestone.title}
|
||||
onChange={(e) => updateMilestone(mi, { title: e.target.value })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{summary.milestones.length > 1 && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeMilestone(mi);
|
||||
}}
|
||||
title="Remove milestone"
|
||||
>
|
||||
<Trash2 size={14} style={{ color: "var(--text-secondary)" }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandedMilestones.has(mi) && (
|
||||
<div style={{ padding: "0 12px 12px 36px" }}>
|
||||
<textarea
|
||||
className="planning-textarea"
|
||||
rows={2}
|
||||
placeholder="Milestone description..."
|
||||
style={{ marginTop: "8px", fontSize: "12px" }}
|
||||
value={milestone.description || ""}
|
||||
onChange={(e) => updateMilestone(mi, { description: e.target.value })}
|
||||
/>
|
||||
<div style={{ marginTop: "6px" }}>
|
||||
<label style={{ fontSize: "11px", color: "var(--text-secondary)", fontWeight: 500 }}>
|
||||
Verification Criteria
|
||||
</label>
|
||||
<textarea
|
||||
className="planning-textarea"
|
||||
rows={2}
|
||||
placeholder="How to confirm this milestone is complete..."
|
||||
style={{ fontSize: "12px", marginTop: "2px" }}
|
||||
value={milestone.verification || ""}
|
||||
onChange={(e) => updateMilestone(mi, { verification: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slices */}
|
||||
{milestone.slices.map((slice, si) => {
|
||||
const sliceKey = `${mi}-${si}`;
|
||||
return (
|
||||
<div
|
||||
key={si}
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "6px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "8px 10px",
|
||||
background: "var(--bg-tertiary)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => toggleSlice(sliceKey)}
|
||||
>
|
||||
{expandedSlices.has(sliceKey) ? (
|
||||
<ChevronDown size={14} style={{ color: "var(--text-secondary)", flexShrink: 0 }} />
|
||||
) : (
|
||||
<ChevronRight size={14} style={{ color: "var(--text-secondary)", flexShrink: 0 }} />
|
||||
)}
|
||||
<Package size={14} style={{ color: "#22c55e", flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ flex: 1, padding: "3px 6px", fontSize: "12px", fontWeight: 500 }}
|
||||
value={slice.title}
|
||||
onChange={(e) => updateSlice(mi, si, { title: e.target.value })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{milestone.slices.length > 1 && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSlice(mi, si);
|
||||
}}
|
||||
title="Remove slice"
|
||||
>
|
||||
<Trash2 size={12} style={{ color: "var(--text-secondary)" }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandedSlices.has(sliceKey) && (
|
||||
<div style={{ padding: "8px 10px 10px 30px" }}>
|
||||
{/* Slice verification */}
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<label style={{ fontSize: "11px", color: "var(--text-secondary)", fontWeight: 500 }}>
|
||||
Slice Verification
|
||||
</label>
|
||||
<textarea
|
||||
className="planning-textarea"
|
||||
rows={1}
|
||||
placeholder="How to confirm this slice is done..."
|
||||
style={{ fontSize: "11px", marginTop: "2px" }}
|
||||
value={slice.verification || ""}
|
||||
onChange={(e) => updateSlice(mi, si, { verification: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{/* Features */}
|
||||
{slice.features.map((feature, fi) => (
|
||||
<div
|
||||
key={fi}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "6px",
|
||||
padding: "6px 0",
|
||||
borderBottom:
|
||||
fi < slice.features.length - 1
|
||||
? "1px solid var(--border-primary)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<Box size={12} style={{ color: "#3b82f6", marginTop: "4px", flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ width: "100%", padding: "2px 6px", fontSize: "12px" }}
|
||||
value={feature.title}
|
||||
onChange={(e) =>
|
||||
updateFeature(mi, si, fi, { title: e.target.value })
|
||||
}
|
||||
/>
|
||||
{feature.description && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "var(--text-secondary)",
|
||||
margin: "2px 0 0 6px",
|
||||
}}
|
||||
>
|
||||
{feature.description}
|
||||
</p>
|
||||
)}
|
||||
{feature.acceptanceCriteria && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "var(--text-secondary)",
|
||||
margin: "2px 0 0 6px",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
AC: {feature.acceptanceCriteria}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => removeFeature(mi, si, fi)}
|
||||
title="Remove feature"
|
||||
>
|
||||
<Trash2 size={12} style={{ color: "var(--text-secondary)" }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "4px 8px",
|
||||
marginTop: "6px",
|
||||
gap: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onClick={() => addFeature(mi, si)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Feature
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-actions planning-summary-actions">
|
||||
<button className="btn" onClick={onStartOver} disabled={isCreating}>
|
||||
<ArrowLeft size={16} style={{ marginRight: "4px" }} />
|
||||
Start Over
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onApprove}
|
||||
disabled={isCreating || summary.milestones.length === 0}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spin" style={{ marginRight: "8px" }} />
|
||||
Creating Mission...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} style={{ marginRight: "8px" }} />
|
||||
Approve Plan
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
Link,
|
||||
Unlink,
|
||||
Play,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { MissionInterviewModal } from "./MissionInterviewModal";
|
||||
import type {
|
||||
Mission,
|
||||
MissionWithHierarchy,
|
||||
@@ -178,6 +180,9 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
const [linkTaskFeatureId, setLinkTaskFeatureId] = useState<string | null>(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState("");
|
||||
|
||||
// AI Interview modal
|
||||
const [showInterviewModal, setShowInterviewModal] = useState(false);
|
||||
|
||||
// Delete confirmation
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
|
||||
|
||||
@@ -1317,10 +1322,16 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
)}
|
||||
|
||||
{!isCreatingMission && (
|
||||
<button className="mission-add-btn" onClick={handleCreateMission}>
|
||||
<Plus size={16} />
|
||||
New Mission
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<button className="mission-add-btn" onClick={() => setShowInterviewModal(true)}>
|
||||
<Sparkles size={16} />
|
||||
Plan with AI
|
||||
</button>
|
||||
<button className="mission-add-btn" onClick={handleCreateMission}>
|
||||
<Plus size={16} />
|
||||
New Mission
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1398,6 +1409,16 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MissionInterviewModal
|
||||
isOpen={showInterviewModal}
|
||||
onClose={() => setShowInterviewModal(false)}
|
||||
onMissionCreated={() => {
|
||||
loadMissions();
|
||||
addToast("Mission created from AI interview", "success");
|
||||
}}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -606,7 +606,7 @@ describe("Mission API", () => {
|
||||
});
|
||||
|
||||
describe("Interview endpoints", () => {
|
||||
it("should return 501 for unimplemented interview endpoints", async () => {
|
||||
it("should return 400 when missionTitle is missing on interview start", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
@@ -615,7 +615,47 @@ describe("Mission API", () => {
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
expect(res.status).toBe(501);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("missionTitle");
|
||||
});
|
||||
|
||||
it("should return 400 when sessionId is missing on interview respond", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/respond",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("sessionId");
|
||||
});
|
||||
|
||||
it("should return 400 when sessionId is missing on interview cancel", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/cancel",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("sessionId");
|
||||
});
|
||||
|
||||
it("should return 400 when sessionId is missing on create-mission", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/create-mission",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("sessionId");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
* Mission Interview Session Management
|
||||
*
|
||||
* Manages AI-guided interview sessions for mission specification.
|
||||
* Mirrors the planning session architecture but produces mission hierarchy
|
||||
* data (milestones, slices, features) instead of task summaries.
|
||||
* Uses an AI agent to conduct back-and-forth conversations that
|
||||
* produce structured mission plans (milestones, slices, features).
|
||||
*
|
||||
* Sessions are stored in-memory with TTL cleanup.
|
||||
* Architecture mirrors planning.ts but targets the mission hierarchy.
|
||||
*
|
||||
* Features:
|
||||
* - Stubbed question flow (scope -> objectives -> dependencies -> summary)
|
||||
* - AI agent integration with real-time streaming via SSE
|
||||
* - Rate limiting per IP
|
||||
* - Session expiration and cleanup
|
||||
* - SSE streaming via MissionInterviewStreamManager
|
||||
@@ -18,6 +18,27 @@ import type { PlanningQuestion } from "@fusion/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports, @typescript-eslint/no-explicit-any
|
||||
type AgentResult = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createKbAgent: any;
|
||||
|
||||
async function initEngine() {
|
||||
if (!createKbAgent) {
|
||||
try {
|
||||
const engineModule = "@fusion/engine";
|
||||
const engine = await import(/* @vite-ignore */ engineModule);
|
||||
createKbAgent = engine.createKbAgent;
|
||||
} catch {
|
||||
// Allow failure in test environments
|
||||
createKbAgent = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const engineReady = initEngine();
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Session TTL in milliseconds (30 minutes) */
|
||||
@@ -32,6 +53,57 @@ 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;
|
||||
|
||||
/** 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
|
||||
- "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, platforms to support)
|
||||
- "confirm": Yes/No questions for quick decisions
|
||||
|
||||
## Guidelines
|
||||
- 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")
|
||||
- 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
|
||||
|
||||
## 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": {"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", "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 */
|
||||
@@ -45,6 +117,7 @@ export interface MissionPlanFeature {
|
||||
export interface MissionPlanSlice {
|
||||
title: string;
|
||||
description?: string;
|
||||
verification?: string;
|
||||
features: MissionPlanFeature[];
|
||||
}
|
||||
|
||||
@@ -52,11 +125,14 @@ export interface MissionPlanSlice {
|
||||
export interface MissionPlanMilestone {
|
||||
title: string;
|
||||
description?: string;
|
||||
verification?: string;
|
||||
slices: MissionPlanSlice[];
|
||||
}
|
||||
|
||||
/** The complete mission plan summary produced by the interview */
|
||||
export interface MissionPlanSummary {
|
||||
missionTitle?: string;
|
||||
missionDescription?: string;
|
||||
milestones: MissionPlanMilestone[];
|
||||
}
|
||||
|
||||
@@ -85,6 +161,8 @@ interface MissionInterviewSession {
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>;
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: MissionPlanSummary;
|
||||
agent?: AgentResult;
|
||||
thinkingOutput: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -105,6 +183,9 @@ function cleanupExpiredSessions(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of sessions) {
|
||||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||
if (session.agent) {
|
||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||
}
|
||||
missionInterviewStreamManager.cleanupSession(id);
|
||||
sessions.delete(id);
|
||||
}
|
||||
@@ -192,178 +273,364 @@ export function getRateLimitResetTime(ip: string): Date | null {
|
||||
return new Date(entry.firstRequestAt.getTime() + RATE_LIMIT_WINDOW_MS);
|
||||
}
|
||||
|
||||
// ── Stubbed Question Generation ─────────────────────────────────────────────
|
||||
// ── JSON Parsing Utilities ─────────────────────────────────────────────────
|
||||
|
||||
function generateFirstQuestion(missionTitle: string): PlanningQuestion {
|
||||
return {
|
||||
id: "q-scope",
|
||||
type: "single_select",
|
||||
question: `What is the scope of "${missionTitle}"?`,
|
||||
description: "This helps determine how many milestones and slices the mission needs.",
|
||||
options: [
|
||||
{ id: "small", label: "Small - 1 milestone, 1-2 slices", description: "Focused objective" },
|
||||
{ id: "medium", label: "Medium - 2-3 milestones, multiple slices", description: "Standard project" },
|
||||
{ id: "large", label: "Large - 3+ milestones, many slices", description: "Complex initiative" },
|
||||
],
|
||||
};
|
||||
/**
|
||||
* Extract the best JSON candidate from AI response text.
|
||||
* Handles markdown-wrapped JSON, embedded prose, and multiple objects.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
function generateNextQuestionOrSummary(session: MissionInterviewSession): MissionInterviewResponse {
|
||||
const historyLength = session.history.length;
|
||||
/**
|
||||
* Attempt to repair common JSON issues.
|
||||
*/
|
||||
function repairJson(text: string): string {
|
||||
let repaired = text;
|
||||
repaired = repaired.replace(/,\s*([}\]])/g, "$1");
|
||||
|
||||
if (historyLength < 2) {
|
||||
return {
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-objectives",
|
||||
type: "text",
|
||||
question: "What are the key objectives or deliverables for this mission?",
|
||||
description: "Describe the main things that need to be built or achieved. Each objective may become a milestone.",
|
||||
},
|
||||
};
|
||||
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 (historyLength < 3) {
|
||||
return {
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-confirm",
|
||||
type: "confirm",
|
||||
question: "Are there dependencies between the milestones that require a specific ordering?",
|
||||
description: "If yes, the milestones will be ordered sequentially. Otherwise they can be worked in parallel.",
|
||||
},
|
||||
};
|
||||
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--;
|
||||
}
|
||||
|
||||
return { type: "complete", data: generateMissionPlanSummary(session) };
|
||||
repaired += "]".repeat(Math.max(0, openBrackets));
|
||||
repaired += "}".repeat(Math.max(0, openBraces));
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
function generateMissionPlanSummary(session: MissionInterviewSession): MissionPlanSummary {
|
||||
const scopeResponse = session.history.find((h) => h.question.id === "q-scope")?.response as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const scope = (scopeResponse?.["q-scope"] as string) || "medium";
|
||||
/**
|
||||
* Parse AI agent response into a MissionInterviewResponse.
|
||||
* Handles markdown wrapping, embedded prose, truncated JSON.
|
||||
*/
|
||||
export function parseMissionAgentResponse(text: string): MissionInterviewResponse {
|
||||
const candidate = extractJsonCandidate(text);
|
||||
|
||||
const objectivesResponse = session.history.find((h) => h.question.id === "q-objectives")?.response as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const objectives = (objectivesResponse?.["q-objectives"] as string) || "";
|
||||
|
||||
// Generate hierarchy based on scope
|
||||
if (scope === "small") {
|
||||
return {
|
||||
milestones: [
|
||||
{
|
||||
title: `${session.missionTitle} - Core Implementation`,
|
||||
description: objectives || undefined,
|
||||
slices: [
|
||||
{
|
||||
title: "Implementation",
|
||||
description: `Core implementation for ${session.missionTitle}`,
|
||||
features: [
|
||||
{ title: "Core functionality", description: "Implement the main feature" },
|
||||
{ title: "Tests", description: "Add test coverage" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (!candidate) {
|
||||
console.error("[mission-interview] No JSON candidate found in agent response:", text.slice(0, 500));
|
||||
throw new Error("AI returned no valid JSON. Please try again.");
|
||||
}
|
||||
|
||||
if (scope === "large") {
|
||||
return {
|
||||
milestones: [
|
||||
{
|
||||
title: "Foundation & Setup",
|
||||
description: "Initial scaffolding and infrastructure",
|
||||
slices: [
|
||||
{
|
||||
title: "Infrastructure",
|
||||
features: [
|
||||
{ title: "Project scaffolding", description: "Set up project structure" },
|
||||
{ title: "Configuration", description: "Configure build and tooling" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Core Implementation",
|
||||
description: objectives || "Main feature development",
|
||||
slices: [
|
||||
{
|
||||
title: "Primary features",
|
||||
features: [
|
||||
{ title: "Core feature 1", description: "First major deliverable" },
|
||||
{ title: "Core feature 2", description: "Second major deliverable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Secondary features",
|
||||
features: [
|
||||
{ title: "Supporting feature", description: "Supporting functionality" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Polish & Release",
|
||||
description: "Testing, documentation, and release preparation",
|
||||
slices: [
|
||||
{
|
||||
title: "Quality assurance",
|
||||
features: [
|
||||
{ title: "Integration tests", description: "End-to-end test coverage" },
|
||||
{ title: "Documentation", description: "User and developer documentation" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
try {
|
||||
const repaired = repairJson(candidate);
|
||||
parsed = JSON.parse(repaired);
|
||||
} catch (repairErr) {
|
||||
console.error("[mission-interview] Failed to parse agent response:", candidate.slice(0, 500));
|
||||
throw new Error(
|
||||
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Medium (default)
|
||||
return {
|
||||
milestones: [
|
||||
{
|
||||
title: "Phase 1 - Setup & Core",
|
||||
description: "Initial setup and core functionality",
|
||||
slices: [
|
||||
{
|
||||
title: "Core implementation",
|
||||
description: objectives || `Core work for ${session.missionTitle}`,
|
||||
features: [
|
||||
{ title: "Core functionality", description: "Implement the main feature" },
|
||||
{ title: "Basic tests", description: "Add initial test coverage" },
|
||||
],
|
||||
},
|
||||
],
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error("[mission-interview] Invalid response structure:", JSON.stringify(parsed).slice(0, 500));
|
||||
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];
|
||||
|
||||
switch (question.type) {
|
||||
case "text":
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
case "single_select":
|
||||
if (typeof responseValue === "string") {
|
||||
const option = question.options?.find((o) => o.id === responseValue);
|
||||
return `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||
}
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
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;
|
||||
});
|
||||
return `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||
}
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
case "confirm":
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||
default:
|
||||
return `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI Agent Integration ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize the AI agent for a session and start the first turn.
|
||||
*/
|
||||
async function initializeAgent(session: MissionInterviewSession, rootDir: string): Promise<void> {
|
||||
try {
|
||||
await engineReady;
|
||||
|
||||
const agentResult = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MISSION_INTERVIEW_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
onThinking: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "thinking",
|
||||
data: delta,
|
||||
});
|
||||
},
|
||||
{
|
||||
title: "Phase 2 - Integration & Delivery",
|
||||
description: "Integration, polish, and delivery",
|
||||
slices: [
|
||||
{
|
||||
title: "Integration",
|
||||
features: [
|
||||
{ title: "Integration work", description: "Connect components together" },
|
||||
{ title: "Final tests & docs", description: "Complete test coverage and documentation" },
|
||||
],
|
||||
},
|
||||
],
|
||||
onText: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
session.agent = agentResult;
|
||||
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) {
|
||||
console.error(`[mission-interview] Agent initialization error for session ${session.id}:`, err);
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: err instanceof Error ? err.message : "Failed to initialize AI agent",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue the AI conversation with a user message.
|
||||
* Includes bounded recovery: one retry on parse failure.
|
||||
*/
|
||||
async function continueAgentConversation(session: MissionInterviewSession, message: string): Promise<void> {
|
||||
if (!session.agent) {
|
||||
throw new InvalidSessionStateError("AI agent not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
session.thinkingOutput = "";
|
||||
|
||||
await session.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 = (session.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) {
|
||||
console.warn(
|
||||
`[mission-interview] Parse attempt ${attempt + 1} failed for session ${session.id}, requesting reformat`
|
||||
);
|
||||
try {
|
||||
session.thinkingOutput = "";
|
||||
await session.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 = (session.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) {
|
||||
console.error(`[mission-interview] Retry prompt failed for session ${session.id}:`, retryErr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
const errorMsg = lastError?.message || "Failed to parse AI response";
|
||||
console.error(`[mission-interview] All parse attempts exhausted for session ${session.id}:`, errorMsg);
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: `${errorMsg} You can try responding again or start a new session.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.updatedAt = new Date();
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "question",
|
||||
data: parsed.data,
|
||||
});
|
||||
} else if (parsed.type === "complete") {
|
||||
session.summary = parsed.data;
|
||||
session.currentQuestion = undefined;
|
||||
session.updatedAt = new Date();
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "summary",
|
||||
data: parsed.data,
|
||||
});
|
||||
missionInterviewStreamManager.broadcast(session.id, { type: "complete" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[mission-interview] Agent conversation error for session ${session.id}:`, err);
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: err instanceof Error ? err.message : "AI processing failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
missionId: string,
|
||||
missionTitle: string
|
||||
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
|
||||
missionTitle: string,
|
||||
rootDir: string
|
||||
): Promise<string> {
|
||||
if (!checkRateLimit(ip)) {
|
||||
const resetTime = getRateLimitResetTime(ip);
|
||||
throw new RateLimitError(
|
||||
@@ -373,24 +640,36 @@ export async function createMissionInterviewSession(
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const firstQuestion = generateFirstQuestion(missionTitle);
|
||||
|
||||
const session: MissionInterviewSession = {
|
||||
id: sessionId,
|
||||
ip,
|
||||
missionId,
|
||||
missionId: "",
|
||||
missionTitle,
|
||||
history: [],
|
||||
currentQuestion: firstQuestion,
|
||||
thinkingOutput: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
return { sessionId, firstQuestion };
|
||||
// Initialize AI agent in background
|
||||
initializeAgent(session, rootDir).catch((err) => {
|
||||
console.error(`[mission-interview] Failed to initialize agent for session ${sessionId}:`, err);
|
||||
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>
|
||||
@@ -404,22 +683,37 @@ export async function submitMissionInterviewResponse(
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
}
|
||||
|
||||
// Record the response
|
||||
session.history.push({
|
||||
question: session.currentQuestion,
|
||||
response: responses,
|
||||
});
|
||||
|
||||
const result = generateNextQuestionOrSummary(session);
|
||||
// If AI agent is active, use it for next question
|
||||
if (session.agent) {
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
|
||||
if (result.type === "question") {
|
||||
session.currentQuestion = result.data;
|
||||
} else {
|
||||
session.summary = result.data;
|
||||
session.currentQuestion = undefined;
|
||||
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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
session.updatedAt = new Date();
|
||||
return result;
|
||||
// No agent — should not happen in normal flow
|
||||
throw new InvalidSessionStateError("AI agent not available for this session");
|
||||
}
|
||||
|
||||
export async function cancelMissionInterviewSession(sessionId: string): Promise<void> {
|
||||
@@ -428,6 +722,11 @@ export async function cancelMissionInterviewSession(sessionId: string): Promise<
|
||||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
if (session.agent) {
|
||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
missionInterviewStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
@@ -441,6 +740,10 @@ export function getMissionInterviewSummary(sessionId: string): MissionPlanSummar
|
||||
}
|
||||
|
||||
export function cleanupMissionInterviewSession(sessionId: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session?.agent) {
|
||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||
}
|
||||
missionInterviewStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
@@ -449,6 +752,11 @@ export function cleanupMissionInterviewSession(sessionId: string): void {
|
||||
* Reset all mission interview state. Used for testing only.
|
||||
*/
|
||||
export function __resetMissionInterviewState(): void {
|
||||
for (const [, session] of sessions) {
|
||||
if (session.agent) {
|
||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
sessions.clear();
|
||||
rateLimits.clear();
|
||||
missionInterviewStreamManager.removeAllListeners();
|
||||
|
||||
@@ -1123,63 +1123,304 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
// ── Interview Endpoints ─────────────────────────────────────────────────────
|
||||
// Note: These are mounted at /api/missions/interview/* via the router
|
||||
|
||||
/**
|
||||
* Helper to resolve rootDir for the current request's project scope.
|
||||
*/
|
||||
async function getRootDirForRequest(req: TypedRequest): Promise<string> {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
|
||||
return scopedStore.getRootDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/missions/interview/start
|
||||
* Start a mission interview session
|
||||
* Start a mission interview session with AI agent streaming.
|
||||
* Body: { missionTitle: string }
|
||||
* Returns: { sessionId: string }
|
||||
*/
|
||||
router.post(
|
||||
"/interview/start",
|
||||
asyncHandler(async (req, res) => {
|
||||
// Placeholder - will be implemented in Step 4
|
||||
res.status(501).json({ error: "Interview system not yet implemented" });
|
||||
const { missionTitle } = req.body;
|
||||
|
||||
if (!missionTitle || typeof missionTitle !== "string" || !missionTitle.trim()) {
|
||||
res.status(400).json({ error: "missionTitle is required and must be a non-empty string" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (missionTitle.length > 500) {
|
||||
res.status(400).json({ error: "missionTitle must be 500 characters or less" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
|
||||
const {
|
||||
createMissionInterviewSession,
|
||||
RateLimitError,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
const sessionId = await createMissionInterviewSession(ip, missionTitle.trim(), rootDir);
|
||||
res.status(201).json({ sessionId });
|
||||
} catch (err: any) {
|
||||
if (err.name === "RateLimitError") {
|
||||
res.status(429).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Failed to start interview session" });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/missions/interview/respond
|
||||
* Submit response to interview question
|
||||
* Submit response to interview question.
|
||||
* Body: { sessionId: string, responses: Record<string, unknown> }
|
||||
*/
|
||||
router.post(
|
||||
"/interview/respond",
|
||||
asyncHandler(async (req, res) => {
|
||||
// Placeholder - will be implemented in Step 4
|
||||
res.status(501).json({ error: "Interview system not yet implemented" });
|
||||
const { sessionId, responses } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
res.status(400).json({ error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!responses || typeof responses !== "object") {
|
||||
res.status(400).json({ error: "responses is required and must be an object" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
submitMissionInterviewResponse,
|
||||
SessionNotFoundError,
|
||||
InvalidSessionStateError,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
const result = await submitMissionInterviewResponse(sessionId, responses);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.name === "InvalidSessionStateError") {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Failed to process response" });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/missions/interview/cancel
|
||||
* Cancel interview session
|
||||
* Cancel and cleanup an interview session.
|
||||
* Body: { sessionId: string }
|
||||
*/
|
||||
router.post(
|
||||
"/interview/cancel",
|
||||
asyncHandler(async (req, res) => {
|
||||
// Placeholder - will be implemented in Step 4
|
||||
res.status(501).json({ error: "Interview system not yet implemented" });
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
res.status(400).json({ error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
cancelMissionInterviewSession,
|
||||
SessionNotFoundError,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
await cancelMissionInterviewSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Failed to cancel session" });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/missions/interview/:sessionId/stream
|
||||
* SSE stream for interview updates
|
||||
* SSE endpoint for real-time interview session updates.
|
||||
* Streams thinking output, questions, summaries, and errors.
|
||||
*/
|
||||
router.get(
|
||||
"/interview/:sessionId/stream",
|
||||
asyncHandler(async (req, res) => {
|
||||
// Placeholder - will be implemented in Step 4/5
|
||||
res.status(501).json({ error: "Interview streaming not yet implemented" });
|
||||
const { sessionId } = req.params;
|
||||
|
||||
// Set SSE headers
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders();
|
||||
|
||||
// Send initial connection confirmation
|
||||
res.write(": connected\n\n");
|
||||
|
||||
try {
|
||||
const {
|
||||
missionInterviewStreamManager,
|
||||
getMissionInterviewSession,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
// Verify session exists
|
||||
const session = getMissionInterviewSession(sessionId);
|
||||
if (!session) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: "Session not found or expired" })}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to session events
|
||||
const unsubscribe = missionInterviewStreamManager.subscribe(sessionId, (event) => {
|
||||
try {
|
||||
const data = (event as { data?: unknown }).data;
|
||||
res.write(`event: ${event.type}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
|
||||
|
||||
// End stream on complete or error
|
||||
if (event.type === "complete" || event.type === "error") {
|
||||
unsubscribe();
|
||||
res.end();
|
||||
}
|
||||
} catch {
|
||||
// Client disconnected
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
req.on("close", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
// Heartbeat every 30s
|
||||
const heartbeat = setInterval(() => {
|
||||
if (res.writableEnded) {
|
||||
clearInterval(heartbeat);
|
||||
return;
|
||||
}
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 30_000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message || "Stream error" })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/missions/interview/create-mission
|
||||
* Create mission from completed interview
|
||||
* Create mission with full hierarchy from completed interview.
|
||||
* Body: { sessionId: string, summary?: MissionPlanSummary }
|
||||
* Returns: MissionWithHierarchy
|
||||
*/
|
||||
router.post(
|
||||
"/interview/create-mission",
|
||||
asyncHandler(async (req, res) => {
|
||||
// Placeholder - will be implemented in Step 4
|
||||
res.status(501).json({ error: "Interview system not yet implemented" });
|
||||
const { sessionId, summary: editedSummary } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
res.status(400).json({ error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
getMissionInterviewSession,
|
||||
getMissionInterviewSummary,
|
||||
cleanupMissionInterviewSession,
|
||||
SessionNotFoundError,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
const session = getMissionInterviewSession(sessionId);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: `Interview session ${sessionId} not found or expired` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use edited summary if provided, otherwise use the session's generated summary
|
||||
const summary = editedSummary || getMissionInterviewSummary(sessionId);
|
||||
if (!summary || !Array.isArray(summary.milestones)) {
|
||||
res.status(400).json({ error: "Interview session is not complete or summary is missing" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the full mission hierarchy
|
||||
const mission = missionStore.createMission({
|
||||
title: summary.missionTitle || session.missionTitle,
|
||||
description: summary.missionDescription,
|
||||
});
|
||||
|
||||
// Update interview state to completed
|
||||
missionStore.updateMission(mission.id, { interviewState: "completed" as InterviewState });
|
||||
|
||||
// Create milestones, slices, and features
|
||||
// Verification criteria are appended to descriptions since the schema
|
||||
// doesn't have dedicated verification fields yet.
|
||||
for (const milestoneData of summary.milestones) {
|
||||
let msDesc = milestoneData.description || "";
|
||||
if (milestoneData.verification) {
|
||||
msDesc += msDesc ? "\n\n" : "";
|
||||
msDesc += `**Verification:** ${milestoneData.verification}`;
|
||||
}
|
||||
const milestone = missionStore.addMilestone(mission.id, {
|
||||
title: milestoneData.title,
|
||||
description: msDesc || undefined,
|
||||
});
|
||||
|
||||
if (Array.isArray(milestoneData.slices)) {
|
||||
for (const sliceData of milestoneData.slices) {
|
||||
let slDesc = sliceData.description || "";
|
||||
if (sliceData.verification) {
|
||||
slDesc += slDesc ? "\n\n" : "";
|
||||
slDesc += `**Verification:** ${sliceData.verification}`;
|
||||
}
|
||||
const slice = missionStore.addSlice(milestone.id, {
|
||||
title: sliceData.title,
|
||||
description: slDesc || undefined,
|
||||
});
|
||||
|
||||
if (Array.isArray(sliceData.features)) {
|
||||
for (const featureData of sliceData.features) {
|
||||
missionStore.addFeature(slice.id, {
|
||||
title: featureData.title,
|
||||
description: featureData.description,
|
||||
acceptanceCriteria: featureData.acceptanceCriteria,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup the interview session
|
||||
cleanupMissionInterviewSession(sessionId);
|
||||
|
||||
// Return the full hierarchy
|
||||
const result = missionStore.getMissionWithHierarchy(mission.id);
|
||||
res.status(201).json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Failed to create mission" });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user