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

View File

@@ -387,6 +387,58 @@
text-align: left; 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 { .session-lock-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -1223,6 +1275,20 @@
border-radius: var(--radius-lg); 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 { .planning-deps-list {
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;

View File

@@ -138,6 +138,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} = useAiSessionSync(); } = useAiSessionSync();
const [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined); const [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = 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 [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null); const [modelsError, setModelsError] = useState<string | null>(null);
@@ -253,6 +255,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsRetrying(false); setIsRetrying(false);
setPlanningModelProvider(undefined); setPlanningModelProvider(undefined);
setPlanningModelId(undefined); setPlanningModelId(undefined);
setPlanningDepth("medium");
setCustomQuestionCount("");
currentSessionIdRef.current = null; currentSessionIdRef.current = null;
setLockSessionId(null); setLockSessionId(null);
}, []); }, []);
@@ -469,7 +473,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
? { planningModelProvider, planningModelId } ? { planningModelProvider, planningModelId }
: undefined; : 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; currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId); setLockSessionId(sessionId);
setSelectedSessionId(sessionId); setSelectedSessionId(sessionId);
@@ -483,7 +501,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
currentSessionIdRef.current = null; currentSessionIdRef.current = null;
setLockSessionId(null); setLockSessionId(null);
} }
}, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]); }, [
connectToPlanningStream,
customQuestionCount,
initialPlan,
planningDepth,
planningModelId,
planningModelProvider,
projectId,
]);
// Focus textarea when opening // Focus textarea when opening
useEffect(() => { useEffect(() => {
@@ -1149,6 +1175,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput(""); setStreamingOutput("");
setPlanningModelProvider(undefined); setPlanningModelProvider(undefined);
setPlanningModelId(undefined); setPlanningModelId(undefined);
setPlanningDepth("medium");
setCustomQuestionCount("");
currentSessionIdRef.current = null; currentSessionIdRef.current = null;
setLockSessionId(null); setLockSessionId(null);
setSelectedSessionId(null); setSelectedSessionId(null);
@@ -1350,6 +1378,40 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div> </div>
</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"> <div className="planning-view-footer">
<button <button
className="btn btn-primary planning-start-btn" className="btn btn-primary planning-start-btn"

View File

@@ -186,7 +186,10 @@ describe("ModalReentry", () => {
// Wait for auto-start (which reads the prop) // Wait for auto-start (which reads the prop)
await waitFor(() => { 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 // localStorage should NOT be read since prop was provided

View File

@@ -381,6 +381,52 @@ describe("PlanningModeModal", () => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, { expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, {
planningModelProvider: "anthropic", planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5", 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")); fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => { 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) // Wait for startPlanningStreaming to be called (allow time for setTimeout in useEffect)
await waitFor(() => { 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 }); }, { timeout: 2000 });
// Should transition to question view // 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) // The auto-start should happen with the initial plan (allow time for setTimeout in useEffect)
await waitFor(() => { 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 }); }, { timeout: 2000 });
}); });
}); });
@@ -496,7 +551,10 @@ describe("PlanningModeModal", () => {
// Wait for streaming to be called // Wait for streaming to be called
await waitFor(() => { 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 // Should transition to question view via streaming

View File

@@ -30,6 +30,7 @@ import {
SessionNotFoundError, SessionNotFoundError,
InvalidSessionStateError, InvalidSessionStateError,
parseAgentResponse, parseAgentResponse,
buildDepthPromptSuffix,
generateSubtasksFromPlanning, generateSubtasksFromPlanning,
formatInterviewQA, formatInterviewQA,
SESSION_TTL_MS, 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", () => { describe("parseAgentResponse", () => {
it("parses clean JSON question response", () => { it("parses clean JSON question response", () => {
const input = '{"type":"question","data":{"id":"q-1","type":"text","question":"What scope?"}}'; 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", () => { 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 () => { it("accepts optional model params in request body", async () => {
const messages: Array<{ role: string; content: string }> = []; const messages: Array<{ role: string; content: string }> = [];
const mockAgent = { const mockAgent = {

View File

@@ -221,6 +221,32 @@ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
/** Generation timeout in milliseconds (120 seconds). */ /** Generation timeout in milliseconds (120 seconds). */
export const GENERATION_TIMEOUT_MS = 120_000; 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 ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
/** SSE event types for planning session streaming */ /** SSE event types for planning session streaming */
@@ -673,6 +699,8 @@ export async function createSession(
_store?: TaskStore, _store?: TaskStore,
rootDir?: string, rootDir?: string,
promptOverrides?: PromptOverrideMap, promptOverrides?: PromptOverrideMap,
planningDepth?: PlanningDepth,
customQuestionCount?: number,
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> { ): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
// Check rate limit // Check rate limit
if (!checkRateLimit(ip)) { if (!checkRateLimit(ip)) {
@@ -704,7 +732,9 @@ export async function createSession(
persistSession(session, "generating"); persistSession(session, "generating");
// Resolve the effective system prompt (override or default) // 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 // Create AI agent and get the first question
// Only await engineReady if createFnAgent hasn't been set externally (e.g., via __setCreateFnAgent) // Only await engineReady if createFnAgent hasn't been set externally (e.g., via __setCreateFnAgent)
@@ -859,7 +889,12 @@ export async function createSessionWithAgent(
modelProvider?: string, modelProvider?: string,
modelId?: string, modelId?: string,
promptOverrides?: PromptOverrideMap, promptOverrides?: PromptOverrideMap,
options?: { projectId?: string; ntfyConfig?: PlanningNtfyConfig }, options?: {
projectId?: string;
ntfyConfig?: PlanningNtfyConfig;
planningDepth?: PlanningDepth;
customQuestionCount?: number;
},
): Promise<string> { ): Promise<string> {
// Check rate limit // Check rate limit
if (!checkRateLimit(ip)) { if (!checkRateLimit(ip)) {
@@ -897,7 +932,15 @@ export async function createSessionWithAgent(
persistSession(session, "generating"); persistSession(session, "generating");
// Initialize AI agent in background - it will stream via planningStreamManager // 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" }); diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
persistSession(session, "error", err.message || "Failed to initialize AI agent"); persistSession(session, "error", err.message || "Failed to initialize AI agent");
planningStreamManager.broadcast(sessionId, { planningStreamManager.broadcast(sessionId, {
@@ -918,9 +961,19 @@ async function initializeAgent(
modelProvider?: string, modelProvider?: string,
modelId?: string, modelId?: string,
promptOverrides?: PromptOverrideMap, promptOverrides?: PromptOverrideMap,
planningDepth?: PlanningDepth,
customQuestionCount?: number,
): Promise<void> { ): Promise<void> {
try { 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(); session.updatedAt = new Date();
// Send initial message to get first question // Send initial message to get first question
@@ -944,12 +997,16 @@ async function createPlanningAgent(
modelProvider?: string, modelProvider?: string,
modelId?: string, modelId?: string,
promptOverrides?: PromptOverrideMap, promptOverrides?: PromptOverrideMap,
planningDepth?: PlanningDepth,
customQuestionCount?: number,
): Promise<AgentResult> { ): Promise<AgentResult> {
// Ensure engine is loaded before using createFnAgent // Ensure engine is loaded before using createFnAgent
await ensureEngineReady(); await ensureEngineReady();
// Resolve the effective system prompt (override or default) // 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({ return createFnAgent({
cwd: rootDir, cwd: rootDir,

View File

@@ -393,12 +393,28 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
*/ */
router.post("/planning/start", async (req, res) => { router.post("/planning/start", async (req, res) => {
try { try {
const { initialPlan } = req.body; const { initialPlan, planningDepth, customQuestionCount } = req.body;
if (!initialPlan || typeof initialPlan !== "string") { if (!initialPlan || typeof initialPlan !== "string") {
throw badRequest("initialPlan is required and must be a 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 { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings(); const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown"; const ip = req.ip || req.socket.remoteAddress || "unknown";
@@ -411,6 +427,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
scopedStore, scopedStore,
rootDir, rootDir,
settings.promptOverrides, settings.promptOverrides,
planningDepth,
customQuestionCount,
); );
res.status(201).json(result); res.status(201).json(result);
} catch (err: unknown) { } catch (err: unknown) {
@@ -438,7 +456,13 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
*/ */
router.post("/planning/start-streaming", async (req, res) => { router.post("/planning/start-streaming", async (req, res) => {
try { try {
const { initialPlan, planningModelProvider, planningModelId } = req.body; const {
initialPlan,
planningModelProvider,
planningModelId,
planningDepth,
customQuestionCount,
} = req.body;
if (!initialPlan || typeof initialPlan !== "string") { if (!initialPlan || typeof initialPlan !== "string") {
throw badRequest("initialPlan is required and must be a 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"); 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 { store: scopedStore, projectId } = await getProjectContext(req);
const settings = await scopedStore.getSettings(); const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown"; const ip = req.ip || req.socket.remoteAddress || "unknown";
@@ -487,6 +527,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
dashboardHost: settings.ntfyDashboardHost, dashboardHost: settings.ntfyDashboardHost,
events: settings.ntfyEvents, events: settings.ntfyEvents,
}, },
planningDepth,
customQuestionCount,
}, },
); );
res.status(201).json({ sessionId }); res.status(201).json({ sessionId });