feat(FN-3105): add planning depth selection to planning flows

- Add planning depth controls and styling in PlanningModeModal with updated modal reentry behavior
- Thread selected depth through legacy frontend API calls and planning route handlers
- Extend planning prompt builder/runtime wiring to honor depth values for subtask planning
- Add route, planning, and modal tests covering depth selector behavior and validation

Fusion-Task-Id: FN-3105
This commit is contained in:
Fusion
2026-05-02 11:09:10 -07:00
committed by gsxdsm
parent 31022b2ec1
commit ffb71b7912
9 changed files with 371 additions and 17 deletions

View File

@@ -2578,10 +2578,18 @@ export type AgentOnboardingStreamEvent =
| { type: "complete"; data: Record<string, never> };
/** Start a new planning session with an initial plan */
export function startPlanning(initialPlan: string, projectId?: string): Promise<PlanningSession> {
export function startPlanning(
initialPlan: string,
projectId?: string,
planningOptions?: { planningDepth?: "small" | "medium" | "large"; customQuestionCount?: number },
): Promise<PlanningSession> {
return api<PlanningSession>(withProjectId("/planning/start", projectId), {
method: "POST",
body: JSON.stringify({ initialPlan }),
body: JSON.stringify({
initialPlan,
planningDepth: planningOptions?.planningDepth,
customQuestionCount: planningOptions?.customQuestionCount,
}),
});
}
@@ -2589,7 +2597,8 @@ export function startPlanning(initialPlan: string, projectId?: string): Promise<
export function startPlanningStreaming(
initialPlan: string,
projectId?: string,
modelOverride?: { planningModelProvider?: string; planningModelId?: string }
modelOverride?: { planningModelProvider?: string; planningModelId?: string },
planningOptions?: { planningDepth?: "small" | "medium" | "large"; customQuestionCount?: number },
): Promise<{ sessionId: string }> {
return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), {
method: "POST",
@@ -2597,6 +2606,8 @@ export function startPlanningStreaming(
initialPlan,
planningModelProvider: modelOverride?.planningModelProvider,
planningModelId: modelOverride?.planningModelId,
planningDepth: planningOptions?.planningDepth,
customQuestionCount: planningOptions?.customQuestionCount,
}),
});
}

View File

@@ -387,6 +387,58 @@
text-align: left;
}
.planning-depth-selector {
display: flex;
align-items: flex-end;
justify-content: center;
gap: var(--space-md);
max-width: 520px;
margin: 0 auto;
width: 100%;
}
.planning-depth-chip-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.planning-depth-chip.btn {
border-radius: var(--radius-pill);
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
min-height: calc(var(--space-lg) + var(--space-xl));
padding: var(--space-sm) var(--space-md);
}
.planning-depth-chip.btn:hover {
border-color: var(--todo);
background: var(--card-hover);
}
.planning-depth-chip-active.btn,
.planning-depth-chip-active.btn:hover {
background: var(--todo);
border-color: var(--todo);
color: var(--bg);
}
.planning-depth-question-count {
display: flex;
flex-direction: column;
gap: var(--space-xs);
align-items: flex-start;
font-size: 12px;
color: var(--text-muted);
}
.planning-depth-question-input.input {
width: calc(var(--space-2xl) * 2 + var(--space-xs));
min-height: calc(var(--space-lg) + var(--space-xl));
padding: var(--space-xs) var(--space-sm);
}
.session-lock-overlay {
position: absolute;
inset: 0;
@@ -1223,6 +1275,20 @@
border-radius: var(--radius-lg);
}
.planning-depth-selector {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.planning-depth-chip-group {
justify-content: flex-start;
}
.planning-depth-question-count {
align-items: flex-start;
}
.planning-deps-list {
flex-direction: column;
flex-wrap: nowrap;

View File

@@ -138,6 +138,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} = useAiSessionSync();
const [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [planningDepth, setPlanningDepth] = useState<"small" | "medium" | "large">("medium");
const [customQuestionCount, setCustomQuestionCount] = useState("");
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
@@ -253,6 +255,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsRetrying(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
setPlanningDepth("medium");
setCustomQuestionCount("");
currentSessionIdRef.current = null;
setLockSessionId(null);
}, []);
@@ -469,7 +473,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
? { planningModelProvider, planningModelId }
: undefined;
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
const parsedCustomQuestionCount = customQuestionCount.trim()
? Number.parseInt(customQuestionCount, 10)
: undefined;
const { sessionId } = await startPlanningStreaming(
plan.trim(),
projectId,
modelOverride,
{
planningDepth,
customQuestionCount: Number.isInteger(parsedCustomQuestionCount)
? parsedCustomQuestionCount
: undefined,
},
);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
setSelectedSessionId(sessionId);
@@ -483,7 +501,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
currentSessionIdRef.current = null;
setLockSessionId(null);
}
}, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]);
}, [
connectToPlanningStream,
customQuestionCount,
initialPlan,
planningDepth,
planningModelId,
planningModelProvider,
projectId,
]);
// Focus textarea when opening
useEffect(() => {
@@ -1149,6 +1175,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput("");
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
setPlanningDepth("medium");
setCustomQuestionCount("");
currentSessionIdRef.current = null;
setLockSessionId(null);
setSelectedSessionId(null);
@@ -1350,6 +1378,40 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div>
</div>
<div className="planning-depth-selector">
<div className="planning-depth-chip-group" role="group" aria-label="Planning depth">
{([
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
{ value: "large", label: "Large" },
] as const).map((depthOption) => (
<button
key={depthOption.value}
type="button"
className={`planning-depth-chip btn ${planningDepth === depthOption.value ? "btn-primary planning-depth-chip-active" : ""}`}
onClick={() => setPlanningDepth(depthOption.value)}
aria-pressed={planningDepth === depthOption.value}
>
{depthOption.label}
</button>
))}
</div>
<label className="planning-depth-question-count" htmlFor="planning-depth-questions">
<span>Questions</span>
<input
id="planning-depth-questions"
className="input planning-depth-question-input"
type="number"
min={1}
max={20}
value={customQuestionCount}
onChange={(e) => setCustomQuestionCount(e.target.value)}
placeholder="Auto"
/>
</label>
</div>
<div className="planning-view-footer">
<button
className="btn btn-primary planning-start-btn"

View File

@@ -186,7 +186,10 @@ describe("ModalReentry", () => {
// Wait for auto-start (which reads the prop)
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("From prop", undefined, undefined);
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("From prop", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
});
});
// localStorage should NOT be read since prop was provided

View File

@@ -381,6 +381,52 @@ describe("PlanningModeModal", () => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, {
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
}, {
planningDepth: "medium",
customQuestionCount: undefined,
});
});
});
it("renders planning depth controls with medium selected by default", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.getByRole("button", { name: "Small" })).toBeDefined();
expect(screen.getByRole("button", { name: "Medium" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "Large" })).toBeDefined();
expect(screen.getByLabelText("Questions")).toBeDefined();
});
it("updates selected depth and sends custom question count", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Large" }));
fireEvent.change(screen.getByLabelText("Questions"), { target: { value: "7" } });
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "large",
customQuestionCount: 7,
});
});
});
@@ -401,7 +447,10 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined);
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
});
});
});
@@ -419,7 +468,10 @@ describe("PlanningModeModal", () => {
// Wait for startPlanningStreaming to be called (allow time for setTimeout in useEffect)
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined, undefined);
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
});
}, { timeout: 2000 });
// Should transition to question view
@@ -442,7 +494,10 @@ describe("PlanningModeModal", () => {
// The auto-start should happen with the initial plan (allow time for setTimeout in useEffect)
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined, undefined);
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
});
}, { timeout: 2000 });
});
});
@@ -496,7 +551,10 @@ describe("PlanningModeModal", () => {
// Wait for streaming to be called
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined);
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
});
});
// Should transition to question view via streaming

View File

@@ -30,6 +30,7 @@ import {
SessionNotFoundError,
InvalidSessionStateError,
parseAgentResponse,
buildDepthPromptSuffix,
generateSubtasksFromPlanning,
formatInterviewQA,
SESSION_TTL_MS,
@@ -1447,6 +1448,28 @@ describe("planning module", () => {
});
});
describe("buildDepthPromptSuffix", () => {
it("returns small depth guidance", () => {
expect(buildDepthPromptSuffix("small")).toContain("Ask exactly 1-2 focused questions");
});
it("returns large depth guidance", () => {
expect(buildDepthPromptSuffix("large")).toContain("Ask 5-8 thorough questions");
});
it("returns custom count guidance", () => {
expect(buildDepthPromptSuffix(undefined, 5)).toBe(
"Ask exactly 5 questions. Adjust depth and breadth to fit within that count.",
);
});
it("prioritizes custom count over depth guidance", () => {
expect(buildDepthPromptSuffix("medium", 7)).toBe(
"Ask exactly 7 questions. Adjust depth and breadth to fit within that count.",
);
});
});
describe("parseAgentResponse", () => {
it("parses clean JSON question response", () => {
const input = '{"type":"question","data":{"id":"q-1","type":"text","question":"What scope?"}}';

View File

@@ -10131,6 +10131,38 @@ describe("Planning Mode Routes", () => {
});
describe("POST /planning/start-streaming", () => {
it("rejects invalid planning depth", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start-streaming",
JSON.stringify({
initialPlan: "Build a user auth system",
planningDepth: "extra-large",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("planningDepth");
});
it("rejects out-of-range custom question count", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start-streaming",
JSON.stringify({
initialPlan: "Build a user auth system",
customQuestionCount: 21,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("customQuestionCount");
});
it("accepts optional model params in request body", async () => {
const messages: Array<{ role: string; content: string }> = [];
const mockAgent = {

View File

@@ -221,6 +221,32 @@ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
/** Generation timeout in milliseconds (120 seconds). */
export const GENERATION_TIMEOUT_MS = 120_000;
export type PlanningDepth = "small" | "medium" | "large";
const PLANNING_DEPTH_PROMPT_SUFFIX: Record<PlanningDepth, string> = {
small:
"Ask exactly 1-2 focused questions. Prioritize speed and getting to a summary quickly. Skip optional clarification.",
medium:
"Ask 3-5 well-rounded questions. Balance breadth and depth. This is the default behavior.",
large:
"Ask 5-8 thorough questions. Deeply explore scope, edge cases, dependencies, and implementation details. Be comprehensive.",
};
export function buildDepthPromptSuffix(
depth?: PlanningDepth,
customQuestionCount?: number,
): string {
if (Number.isInteger(customQuestionCount) && (customQuestionCount ?? 0) > 0) {
return `Ask exactly ${customQuestionCount} questions. Adjust depth and breadth to fit within that count.`;
}
if (!depth) {
return "";
}
return PLANNING_DEPTH_PROMPT_SUFFIX[depth];
}
// ── Types ───────────────────────────────────────────────────────────────────
/** SSE event types for planning session streaming */
@@ -673,6 +699,8 @@ export async function createSession(
_store?: TaskStore,
rootDir?: string,
promptOverrides?: PromptOverrideMap,
planningDepth?: PlanningDepth,
customQuestionCount?: number,
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
// Check rate limit
if (!checkRateLimit(ip)) {
@@ -704,7 +732,9 @@ export async function createSession(
persistSession(session, "generating");
// Resolve the effective system prompt (override or default)
const systemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
const baseSystemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
const depthPromptSuffix = buildDepthPromptSuffix(planningDepth, customQuestionCount);
const systemPrompt = depthPromptSuffix ? `${baseSystemPrompt}\n\n${depthPromptSuffix}` : baseSystemPrompt;
// Create AI agent and get the first question
// Only await engineReady if createFnAgent hasn't been set externally (e.g., via __setCreateFnAgent)
@@ -859,7 +889,12 @@ export async function createSessionWithAgent(
modelProvider?: string,
modelId?: string,
promptOverrides?: PromptOverrideMap,
options?: { projectId?: string; ntfyConfig?: PlanningNtfyConfig },
options?: {
projectId?: string;
ntfyConfig?: PlanningNtfyConfig;
planningDepth?: PlanningDepth;
customQuestionCount?: number;
},
): Promise<string> {
// Check rate limit
if (!checkRateLimit(ip)) {
@@ -897,7 +932,15 @@ export async function createSessionWithAgent(
persistSession(session, "generating");
// Initialize AI agent in background - it will stream via planningStreamManager
initializeAgent(session, rootDir, modelProvider, modelId, promptOverrides).catch((err) => {
initializeAgent(
session,
rootDir,
modelProvider,
modelId,
promptOverrides,
options?.planningDepth,
options?.customQuestionCount,
).catch((err) => {
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
persistSession(session, "error", err.message || "Failed to initialize AI agent");
planningStreamManager.broadcast(sessionId, {
@@ -918,9 +961,19 @@ async function initializeAgent(
modelProvider?: string,
modelId?: string,
promptOverrides?: PromptOverrideMap,
planningDepth?: PlanningDepth,
customQuestionCount?: number,
): Promise<void> {
try {
session.agent = await createPlanningAgent(session, rootDir, modelProvider, modelId, promptOverrides);
session.agent = await createPlanningAgent(
session,
rootDir,
modelProvider,
modelId,
promptOverrides,
planningDepth,
customQuestionCount,
);
session.updatedAt = new Date();
// Send initial message to get first question
@@ -944,12 +997,16 @@ async function createPlanningAgent(
modelProvider?: string,
modelId?: string,
promptOverrides?: PromptOverrideMap,
planningDepth?: PlanningDepth,
customQuestionCount?: number,
): Promise<AgentResult> {
// Ensure engine is loaded before using createFnAgent
await ensureEngineReady();
// Resolve the effective system prompt (override or default)
const systemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
const baseSystemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
const depthPromptSuffix = buildDepthPromptSuffix(planningDepth, customQuestionCount);
const systemPrompt = depthPromptSuffix ? `${baseSystemPrompt}\n\n${depthPromptSuffix}` : baseSystemPrompt;
return createFnAgent({
cwd: rootDir,

View File

@@ -393,12 +393,28 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
*/
router.post("/planning/start", async (req, res) => {
try {
const { initialPlan } = req.body;
const { initialPlan, planningDepth, customQuestionCount } = req.body;
if (!initialPlan || typeof initialPlan !== "string") {
throw badRequest("initialPlan is required and must be a string");
}
if (
planningDepth !== undefined
&& planningDepth !== "small"
&& planningDepth !== "medium"
&& planningDepth !== "large"
) {
throw badRequest('planningDepth must be one of "small", "medium", or "large" when provided');
}
if (
customQuestionCount !== undefined
&& (!Number.isInteger(customQuestionCount) || customQuestionCount < 1 || customQuestionCount > 20)
) {
throw badRequest("customQuestionCount must be an integer between 1 and 20 when provided");
}
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown";
@@ -411,6 +427,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
scopedStore,
rootDir,
settings.promptOverrides,
planningDepth,
customQuestionCount,
);
res.status(201).json(result);
} catch (err: unknown) {
@@ -438,7 +456,13 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
*/
router.post("/planning/start-streaming", async (req, res) => {
try {
const { initialPlan, planningModelProvider, planningModelId } = req.body;
const {
initialPlan,
planningModelProvider,
planningModelId,
planningDepth,
customQuestionCount,
} = req.body;
if (!initialPlan || typeof initialPlan !== "string") {
throw badRequest("initialPlan is required and must be a string");
@@ -452,6 +476,22 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
throw badRequest("planningModelId must be a string when provided");
}
if (
planningDepth !== undefined
&& planningDepth !== "small"
&& planningDepth !== "medium"
&& planningDepth !== "large"
) {
throw badRequest('planningDepth must be one of "small", "medium", or "large" when provided');
}
if (
customQuestionCount !== undefined
&& (!Number.isInteger(customQuestionCount) || customQuestionCount < 1 || customQuestionCount > 20)
) {
throw badRequest("customQuestionCount must be an integer between 1 and 20 when provided");
}
const { store: scopedStore, projectId } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown";
@@ -487,6 +527,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
dashboardHost: settings.ntfyDashboardHost,
events: settings.ntfyEvents,
},
planningDepth,
customQuestionCount,
},
);
res.status(201).json({ sessionId });