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:
@@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?"}}';
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user