feat(FN-1621): merge fusion/fn-1621

This commit is contained in:
gsxdsm
2026-04-12 19:16:21 -07:00
parent 745c9c7ff4
commit aa6056d095
10 changed files with 219 additions and 12 deletions

View File

@@ -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,
}),
});
}

View File

@@ -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();
});

View File

@@ -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">

View File

@@ -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);
});
});

View File

@@ -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: [] }),
};
});

View File

@@ -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

View File

@@ -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,
);
});

View File

@@ -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(),
};

View File

@@ -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) {