feat(FN-1621): merge fusion/fn-1621
This commit is contained in:
@@ -3867,10 +3867,18 @@ export type MissionInterviewResponse =
|
||||
| { type: "complete"; data: MissionPlanSummary };
|
||||
|
||||
/** Start a mission interview session with AI streaming */
|
||||
export function startMissionInterview(missionTitle: string, projectId?: string): Promise<{ sessionId: string }> {
|
||||
export function startMissionInterview(
|
||||
missionTitle: string,
|
||||
projectId?: string,
|
||||
modelOverride?: { modelProvider?: string; modelId?: string },
|
||||
): Promise<{ sessionId: string }> {
|
||||
return api<{ sessionId: string }>(withProjectId("/missions/interview/start", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ missionTitle }),
|
||||
body: JSON.stringify({
|
||||
missionTitle,
|
||||
modelProvider: modelOverride?.modelProvider,
|
||||
modelId: modelOverride?.modelId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const mockParseConversationHistory = vi.fn();
|
||||
const mockAcquireSessionLock = vi.fn();
|
||||
const mockReleaseSessionLock = vi.fn();
|
||||
const mockForceAcquireSessionLock = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
||||
@@ -26,6 +27,7 @@ vi.mock("../api", () => ({
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/modalPersistence", () => ({
|
||||
@@ -74,6 +76,7 @@ describe("MissionInterviewModal", () => {
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
});
|
||||
|
||||
function renderModal() {
|
||||
@@ -121,7 +124,7 @@ describe("MissionInterviewModal", () => {
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Build a mission planning workflow", undefined);
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Build a mission planning workflow", undefined, undefined);
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
connectMissionInterviewStream,
|
||||
fetchAiSession,
|
||||
parseConversationHistory,
|
||||
fetchModels,
|
||||
type MissionPlanSummary,
|
||||
type ConversationHistoryEntry,
|
||||
type MissionPlanMilestone,
|
||||
type MissionPlanSlice,
|
||||
type MissionPlanFeature,
|
||||
type MissionWithHierarchy,
|
||||
type ModelInfo,
|
||||
} from "../api";
|
||||
import {
|
||||
saveMissionGoal,
|
||||
@@ -41,10 +43,32 @@ import {
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
// Helper functions for model selection
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||
}
|
||||
|
||||
function parseModelSelection(value: string): { provider?: string; modelId?: string } {
|
||||
if (!value) {
|
||||
return { provider: undefined, modelId: undefined };
|
||||
}
|
||||
|
||||
const slashIndex = value.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return { provider: undefined, modelId: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
provider: value.slice(0, slashIndex),
|
||||
modelId: value.slice(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
interface MissionInterviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -113,6 +137,56 @@ export function MissionInterviewModal({
|
||||
broadcastHeartbeat,
|
||||
} = useAiSessionSync();
|
||||
|
||||
// Model selection state
|
||||
const [modelProvider, setModelProvider] = useState<string | undefined>(undefined);
|
||||
const [modelId, setModelId] = useState<string | undefined>(undefined);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(true);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
|
||||
const modelSelectionValue = getModelSelectionValue(modelProvider, modelId);
|
||||
|
||||
// Load models on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setModelsLoading(true);
|
||||
const resp = await fetchModels();
|
||||
setLoadedModels(resp.models);
|
||||
setFavoriteProviders(resp.favoriteProviders);
|
||||
setFavoriteModels(resp.favoriteModels);
|
||||
} catch (err: any) {
|
||||
setModelsError(err.message || "Failed to load models");
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const handleToggleFavoriteProvider = useCallback((provider: string) => {
|
||||
setFavoriteProviders((prev) =>
|
||||
prev.includes(provider) ? prev.filter((item) => item !== provider) : [...prev, provider],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleToggleFavoriteModel = useCallback((modelIdToToggle: string) => {
|
||||
setFavoriteModels((prev) =>
|
||||
prev.includes(modelIdToToggle) ? prev.filter((item) => item !== modelIdToToggle) : [...prev, modelIdToToggle],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const getModelBadgeLabel = useCallback(
|
||||
(provider?: string, mid?: string) => {
|
||||
if (!provider || !mid) return "Using default";
|
||||
const matched = loadedModels.find((model) => model.provider === provider && model.id === mid);
|
||||
return matched ? `${matched.provider}/${matched.id}` : `${provider}/${mid}`;
|
||||
},
|
||||
[loadedModels],
|
||||
);
|
||||
|
||||
const connectToMissionInterviewStream = useCallback(
|
||||
(sessionId: string) => {
|
||||
streamConnectionRef.current?.close();
|
||||
@@ -216,7 +290,11 @@ export function MissionInterviewModal({
|
||||
setView({ type: "loading" });
|
||||
|
||||
try {
|
||||
const { sessionId } = await startMissionInterview(goal.trim(), projectId);
|
||||
const { sessionId } = await startMissionInterview(
|
||||
goal.trim(),
|
||||
projectId,
|
||||
modelProvider && modelId ? { modelProvider, modelId } : undefined,
|
||||
);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
setLockSessionId(sessionId);
|
||||
clearMissionGoal(projectId);
|
||||
@@ -231,7 +309,7 @@ export function MissionInterviewModal({
|
||||
setLockSessionId(null);
|
||||
}
|
||||
},
|
||||
[connectToMissionInterviewStream, missionGoal, projectId]
|
||||
[connectToMissionInterviewStream, missionGoal, modelProvider, modelId, projectId]
|
||||
);
|
||||
|
||||
// Focus textarea when opening
|
||||
@@ -446,6 +524,8 @@ export function MissionInterviewModal({
|
||||
setIsRetrying(false);
|
||||
setHasProgress(false);
|
||||
setIsCreating(false);
|
||||
setModelProvider(undefined);
|
||||
setModelId(undefined);
|
||||
currentSessionIdRef.current = null;
|
||||
setLockSessionId(null);
|
||||
onClose();
|
||||
@@ -659,6 +739,71 @@ export function MissionInterviewModal({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-model-select-group">
|
||||
<label htmlFor="mission-interview-modal-model" className="form-label">
|
||||
Planning Model
|
||||
{modelsLoading && (
|
||||
<span className="text-muted text-muted-sm">
|
||||
Loading models…
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<CustomModelDropdown
|
||||
id="mission-interview-modal-model"
|
||||
label="Planning Model"
|
||||
value={modelSelectionValue}
|
||||
onChange={(value) => {
|
||||
const { provider, modelId: selectedModelId } = parseModelSelection(value);
|
||||
setModelProvider(provider);
|
||||
setModelId(selectedModelId);
|
||||
}}
|
||||
models={loadedModels}
|
||||
disabled={modelsLoading}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavoriteProvider}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleFavoriteModel}
|
||||
/>
|
||||
{modelsError && (
|
||||
<div className="form-hint form-hint-error">
|
||||
{modelsError}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-link-btn"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setModelsLoading(true);
|
||||
const resp = await fetchModels();
|
||||
setLoadedModels(resp.models);
|
||||
setFavoriteProviders(resp.favoriteProviders);
|
||||
setFavoriteModels(resp.favoriteModels);
|
||||
setModelsError(null);
|
||||
} catch (err: any) {
|
||||
setModelsError(err.message || "Failed to load models");
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="model-selector-current model-selector-current--spaced">
|
||||
<span
|
||||
className={`model-badge ${
|
||||
modelProvider && modelId
|
||||
? "model-badge-custom"
|
||||
: "model-badge-default"
|
||||
}`}
|
||||
>
|
||||
{getModelBadgeLabel(modelProvider, modelId)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-view-footer">
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("../../api", () => ({
|
||||
acquireSessionLock: vi.fn(),
|
||||
releaseSessionLock: vi.fn(),
|
||||
forceAcquireSessionLock: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/modalPersistence", () => ({
|
||||
@@ -35,6 +36,7 @@ const mockParseConversationHistory = vi.mocked(api.parseConversationHistory);
|
||||
const mockAcquireSessionLock = vi.mocked(api.acquireSessionLock);
|
||||
const mockReleaseSessionLock = vi.mocked(api.releaseSessionLock);
|
||||
const mockForceAcquireSessionLock = vi.mocked(api.forceAcquireSessionLock);
|
||||
const mockFetchModels = vi.mocked(api.fetchModels);
|
||||
const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
|
||||
|
||||
const sampleQuestionSingle: PlanningQuestion = {
|
||||
@@ -105,6 +107,7 @@ describe("MissionInterviewModal", () => {
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
@@ -136,7 +139,7 @@ describe("MissionInterviewModal", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Start Interview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith(goal, undefined);
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith(goal, undefined, undefined);
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
}
|
||||
@@ -385,7 +388,7 @@ describe("MissionInterviewModal", () => {
|
||||
renderModal({ initialGoal: "Auto-start goal" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Auto-start goal", undefined);
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Auto-start goal", undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("../../api", async () => {
|
||||
skipMilestoneInterview: (...args: any[]) => mockSkipMilestoneInterview(...args),
|
||||
skipSliceInterview: (...args: any[]) => mockSkipSliceInterview(...args),
|
||||
triageFeature: (...args: any[]) => mockTriageFeature(...args),
|
||||
fetchModels: () => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ describe("ModalReentry", () => {
|
||||
|
||||
// Wait for auto-start
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("From prop", undefined);
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("From prop", undefined, undefined);
|
||||
});
|
||||
|
||||
// localStorage should NOT be read since prop was provided
|
||||
|
||||
@@ -2377,6 +2377,8 @@ describe("Mission API", () => {
|
||||
"Scoped Mission",
|
||||
scopedRootDir,
|
||||
{ "mission-interview-system": "Scoped mission interview prompt" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2462,6 +2464,8 @@ describe("Mission API", () => {
|
||||
"Default Mission",
|
||||
"/fake/root",
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -196,6 +196,9 @@ interface MissionInterviewSession {
|
||||
thinkingOutput: string;
|
||||
/** Thinking output generated while producing currentQuestion */
|
||||
lastGeneratedThinking: string;
|
||||
/** Model override for this interview session */
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -274,6 +277,8 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera
|
||||
ip: session.ip,
|
||||
missionTitle: session.missionTitle,
|
||||
missionId: session.missionId,
|
||||
modelProvider: session.modelProvider,
|
||||
modelId: session.modelId,
|
||||
}),
|
||||
conversationHistory: JSON.stringify(session.history),
|
||||
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
|
||||
@@ -300,7 +305,7 @@ function unpersistMissionSession(sessionId: string): void {
|
||||
}
|
||||
|
||||
function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionInterviewSession {
|
||||
const payload = safeParseJson<{ ip?: string; missionId?: string; missionTitle?: string }>(
|
||||
const payload = safeParseJson<{ ip?: string; missionId?: string; missionTitle?: string; modelProvider?: string; modelId?: string }>(
|
||||
row.inputPayload,
|
||||
{},
|
||||
{ throwOnError: true, fieldName: "inputPayload" },
|
||||
@@ -338,6 +343,8 @@ function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionIntervie
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
lastGeneratedThinking: row.thinkingOutput || "",
|
||||
error: row.error ?? undefined,
|
||||
modelProvider: payload.modelProvider,
|
||||
modelId: payload.modelId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
agent: undefined,
|
||||
@@ -749,6 +756,12 @@ async function createMissionInterviewAgent(
|
||||
cwd: rootDir,
|
||||
systemPrompt: effectivePrompt,
|
||||
tools: "readonly",
|
||||
...(session.modelProvider && session.modelId
|
||||
? {
|
||||
defaultProvider: session.modelProvider,
|
||||
defaultModelId: session.modelId,
|
||||
}
|
||||
: {}),
|
||||
onThinking: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
persistMissionThinking(session.id, session.thinkingOutput);
|
||||
@@ -963,6 +976,8 @@ export async function createMissionInterviewSession(
|
||||
missionTitle: string,
|
||||
rootDir: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
): Promise<string> {
|
||||
if (!checkRateLimit(ip)) {
|
||||
const resetTime = getRateLimitResetTime(ip);
|
||||
@@ -982,6 +997,8 @@ export async function createMissionInterviewSession(
|
||||
history: [],
|
||||
thinkingOutput: "",
|
||||
lastGeneratedThinking: "",
|
||||
modelProvider,
|
||||
modelId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -354,13 +354,13 @@ export function createMissionRouter(
|
||||
/**
|
||||
* POST /api/missions/interview/start
|
||||
* Start a mission interview session with AI agent streaming.
|
||||
* Body: { missionTitle: string }
|
||||
* Body: { missionTitle: string, modelProvider?: string, modelId?: string }
|
||||
* Returns: { sessionId: string }
|
||||
*/
|
||||
router.post(
|
||||
"/interview/start",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { missionTitle } = req.body;
|
||||
const { missionTitle, modelProvider, modelId } = req.body;
|
||||
|
||||
if (!missionTitle || typeof missionTitle !== "string" || !missionTitle.trim()) {
|
||||
throw badRequest("missionTitle is required and must be a non-empty string");
|
||||
@@ -370,6 +370,19 @@ export function createMissionRouter(
|
||||
throw badRequest("missionTitle must be 500 characters or less");
|
||||
}
|
||||
|
||||
// Validate model parameters - if one is provided, both must be provided
|
||||
if (modelProvider !== undefined && typeof modelProvider !== "string") {
|
||||
throw badRequest("modelProvider must be a string when provided");
|
||||
}
|
||||
|
||||
if (modelId !== undefined && typeof modelId !== "string") {
|
||||
throw badRequest("modelId must be a string when provided");
|
||||
}
|
||||
|
||||
if ((modelProvider && !modelId) || (!modelProvider && modelId)) {
|
||||
throw badRequest("Both modelProvider and modelId must be provided together, or neither should be provided");
|
||||
}
|
||||
|
||||
try {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const scopedStore = await getScopedStoreForRequest(req);
|
||||
@@ -386,6 +399,8 @@ export function createMissionRouter(
|
||||
missionTitle.trim(),
|
||||
rootDir,
|
||||
settings.promptOverrides,
|
||||
modelProvider,
|
||||
modelId,
|
||||
);
|
||||
res.status(201).json({ sessionId });
|
||||
} catch (err: any) {
|
||||
|
||||
Reference in New Issue
Block a user