feat(FN-892): add planning mode multi-task creation with Break into Tasks

- Add generateSubtasksFromPlanning function to generate subtasks from planning session key deliverables
- Add POST /planning/start-breakdown and POST /planning/create-tasks API routes
- Add startPlanningBreakdown and createTasksFromPlanning frontend API functions
- Extend PlanningModeModal with Break into Tasks UI and subtask editing (drag-and-drop, dependency validation)
- Add comprehensive tests for generateSubtasksFromPlanning with edge cases
- Document planning mode multi-task creation feature in AGENTS.md
This commit is contained in:
gsxdsm
2026-04-04 14:25:34 -07:00
parent d2bfe56874
commit 78e95246d8
8 changed files with 931 additions and 11 deletions

View File

@@ -381,6 +381,13 @@ function AppInner() {
setPlanningInitialPlan(null);
}, [addToast]);
const handlePlanningTasksCreated = useCallback((createdTasks: Task[]) => {
const ids = createdTasks.map((task) => task.id).join(", ");
addToast(`Created ${ids} from planning mode`, "success");
setIsPlanningOpen(false);
setPlanningInitialPlan(null);
}, [addToast]);
// Handle planning mode from new task dialog
const handleNewTaskPlanningMode = useCallback((initialPlan: string) => {
setPlanningInitialPlan(initialPlan);
@@ -699,6 +706,7 @@ function AppInner() {
isOpen={isPlanningOpen}
onClose={handlePlanningClose}
onTaskCreated={handlePlanningTaskCreated}
onTasksCreated={handlePlanningTasksCreated}
tasks={tasks}
initialPlan={planningInitialPlan ?? undefined}
projectId={currentProject?.id}

View File

@@ -1165,6 +1165,39 @@ export function createTaskFromPlanning(sessionId: string, projectId?: string): P
});
}
/** Start subtask breakdown from a completed planning session */
export function startPlanningBreakdown(
sessionId: string,
projectId?: string,
): Promise<{ sessionId: string; subtasks: SubtaskItem[] }> {
return api<{ sessionId: string; subtasks: SubtaskItem[] }>(
withProjectId("/planning/start-breakdown", projectId),
{
method: "POST",
body: JSON.stringify({ sessionId }),
},
);
}
/** Create multiple tasks from a completed planning session */
export function createTasksFromPlanning(
planningSessionId: string,
subtasks: Array<{
id: string;
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
dependsOn: string[];
}>,
projectId?: string,
): Promise<{ tasks: Task[] }> {
return api<{ tasks: Task[] }>(withProjectId("/planning/create-tasks", projectId), {
method: "POST",
body: JSON.stringify({ planningSessionId, subtasks }),
});
}
/** Get the SSE stream URL for a planning session */
export function getPlanningStreamUrl(sessionId: string, projectId?: string): string {
return buildApiUrl(withProjectId(`/planning/${encodeURIComponent(sessionId)}/stream`, projectId));

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import type { Task, PlanningQuestion, PlanningSummary } from "@fusion/core";
import {
startPlanning,
@@ -8,14 +8,18 @@ import {
createTaskFromPlanning,
connectPlanningStream,
fetchAiSession,
startPlanningBreakdown,
createTasksFromPlanning,
type PlanningSession,
type SubtaskItem,
} from "../api";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from "lucide-react";
interface PlanningModeModalProps {
isOpen: boolean;
onClose: () => void;
onTaskCreated: (task: Task) => void;
onTasksCreated: (tasks: Task[]) => void;
tasks: Task[];
initialPlan?: string;
projectId?: string;
@@ -31,7 +35,9 @@ type ViewState =
| { type: "initial" }
| { type: "question"; session: PlanningSession }
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
| { type: "loading" };
| { type: "breakdown"; sessionId: string; subtasks: SubtaskItem[]; dirty: boolean }
| { type: "loading" }
| { type: "creating" };
const EXAMPLE_PLANS = [
"Build a user authentication system with login and signup",
@@ -40,7 +46,7 @@ const EXAMPLE_PLANS = [
"Refactor the task card component for better performance",
];
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initialPlan: initialPlanProp, projectId, resumeSessionId }: PlanningModeModalProps) {
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, resumeSessionId }: PlanningModeModalProps) {
const [initialPlan, setInitialPlan] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
@@ -317,6 +323,51 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
}
}, [view, onTaskCreated, handleCancel]);
const handleStartBreakdown = useCallback(async () => {
if (view.type !== "summary") return;
setError(null);
setView({ type: "loading" });
try {
const result = await startPlanningBreakdown(view.session.sessionId, projectId);
setView({
type: "breakdown",
sessionId: result.sessionId,
subtasks: result.subtasks,
dirty: false,
});
} catch (err: any) {
setError(err.message || "Failed to start breakdown");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [view, projectId]);
const handleCreateTasksFromBreakdown = useCallback(async () => {
if (view.type !== "breakdown") return;
setError(null);
setView({ type: "creating" });
try {
const result = await createTasksFromPlanning(view.sessionId, view.subtasks, projectId);
onTasksCreated(result.tasks);
// Reset and close
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setHasProgress(false);
currentSessionIdRef.current = null;
onClose();
} catch (err: any) {
setError(err.message || "Failed to create tasks");
setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty });
}
}, [view, onTasksCreated, onClose, projectId]);
const handleBack = useCallback(() => {
if (view.type === "question" && responseHistory.length > 0) {
// Remove last response and go back
@@ -434,6 +485,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
</div>
)}
{view.type === "creating" && (
<div className="planning-loading">
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
<p>Creating tasks...</p>
</div>
)}
{view.type === "question" && view.session.currentQuestion && (
<div className="planning-question">
<QuestionForm
@@ -451,6 +509,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
onSummaryChange={setEditedSummary}
tasks={tasks}
onCreateTask={handleCreateTask}
onBreakIntoTasks={handleStartBreakdown}
onRefine={() => {
// Reset to question mode for more refinement
setView({ type: "question", session: view.session });
@@ -458,6 +517,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
isLoading={false}
/>
)}
{view.type === "breakdown" && (
<BreakdownView
subtasks={view.subtasks}
dirty={view.dirty}
isLoading={false}
onUpdateSubtasks={(newSubtasks) =>
setView({ ...view, subtasks: newSubtasks, dirty: true })
}
onCreateTasks={handleCreateTasksFromBreakdown}
onBack={() => {
// Return to summary view — re-fetch the session
const sessionId = view.sessionId;
const session: PlanningSession = {
sessionId,
currentQuestion: null,
summary: editedSummary ?? null,
};
if (editedSummary) {
setView({ type: "summary", session, summary: editedSummary });
}
}}
/>
)}
</div>
</div>
</div>
@@ -644,6 +727,7 @@ interface SummaryViewProps {
onSummaryChange: (summary: PlanningSummary) => void;
tasks: Task[];
onCreateTask: () => void;
onBreakIntoTasks: () => void;
onRefine: () => void;
isLoading: boolean;
}
@@ -653,6 +737,7 @@ function SummaryView({
onSummaryChange,
tasks,
onCreateTask,
onBreakIntoTasks,
onRefine,
isLoading,
}: SummaryViewProps) {
@@ -761,21 +846,400 @@ function SummaryView({
<ArrowLeft size={16} style={{ marginRight: "4px" }} />
Refine Further
</button>
<div className="planning-summary-actions-right">
<button
className="btn"
onClick={onBreakIntoTasks}
disabled={isLoading}
title="Break the plan into multiple tasks with dependencies"
>
{isLoading ? (
<>
<Loader2 size={16} className="spin" style={{ marginRight: "8px" }} />
Breaking down...
</>
) : (
<>
<ListTree size={16} style={{ marginRight: "8px" }} />
Break into Tasks
</>
)}
</button>
<button
className="btn btn-primary"
onClick={onCreateTask}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 size={16} className="spin" style={{ marginRight: "8px" }} />
Creating...
</>
) : (
<>
<CheckCircle size={16} style={{ marginRight: "8px" }} />
Create Task
</>
)}
</button>
</div>
</div>
</div>
);
}
// ── BreakdownView (subtask editing in planning modal) ──────────────────────
function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
const graph = new Map(subtasks.map((item) => [item.id, item.dependsOn]));
const visiting = new Set<string>();
const visited = new Set<string>();
const visit = (id: string): boolean => {
if (visiting.has(id)) return true;
if (visited.has(id)) return false;
visiting.add(id);
for (const dep of graph.get(id) ?? []) {
if (graph.has(dep) && visit(dep)) return true;
}
visiting.delete(id);
visited.add(id);
return false;
};
return subtasks.some((item) => visit(item.id));
}
function createEmptySubtask(index: number): SubtaskItem {
return {
id: `subtask-${index}`,
title: "",
description: "",
suggestedSize: "M",
dependsOn: [],
};
}
interface BreakdownViewProps {
subtasks: SubtaskItem[];
dirty: boolean;
isLoading: boolean;
onUpdateSubtasks: (subtasks: SubtaskItem[]) => void;
onCreateTasks: () => void;
onBack: () => void;
}
function BreakdownView({
subtasks,
dirty: _dirty,
isLoading,
onUpdateSubtasks,
onCreateTasks,
onBack,
}: BreakdownViewProps) {
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [dragOverPosition, setDragOverPosition] = useState<"before" | "after" | null>(null);
const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
const isInvalid = useMemo(() => {
if (subtasks.length === 0) return true;
if (subtasks.some((s) => !s.title.trim())) return true;
return hasDependencyCycle(subtasks);
}, [subtasks]);
const updateSubtask = useCallback(
(id: string, patch: Partial<SubtaskItem>) => {
onUpdateSubtasks(subtasks.map((item) => (item.id === id ? { ...item, ...patch } : item)));
},
[subtasks, onUpdateSubtasks],
);
const addSubtask = useCallback(() => {
onUpdateSubtasks([...subtasks, createEmptySubtask(subtasks.length + 1)]);
}, [subtasks, onUpdateSubtasks]);
const removeSubtask = useCallback(
(id: string) => {
onUpdateSubtasks(
subtasks
.filter((item) => item.id !== id)
.map((item) => ({ ...item, dependsOn: item.dependsOn.filter((dep) => dep !== id) })),
);
},
[subtasks, onUpdateSubtasks],
);
const moveSubtask = useCallback(
(fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= subtasks.length) return;
const newSubtasks = [...subtasks];
const [moved] = newSubtasks.splice(fromIndex, 1);
newSubtasks.splice(toIndex, 0, moved);
onUpdateSubtasks(newSubtasks);
},
[subtasks, onUpdateSubtasks],
);
// Drag-and-drop handlers
const handleDragStart = useCallback((subtaskId: string) => (e: React.DragEvent) => {
setDraggingId(subtaskId);
e.dataTransfer.setData("text/plain", subtaskId);
e.dataTransfer.effectAllowed = "move";
}, []);
const handleDragEnd = useCallback(() => {
setDraggingId(null);
setDragOverId(null);
setDragOverPosition(null);
}, []);
const handleDragOver = useCallback((targetId: string) => (e: React.DragEvent) => {
e.preventDefault();
if (targetId === draggingId) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: "before" | "after" = e.clientY < midY ? "before" : "after";
setDragOverId(targetId);
setDragOverPosition(position);
}, [draggingId]);
const handleDrop = useCallback((targetId: string) => (e: React.DragEvent) => {
e.preventDefault();
const draggedId = e.dataTransfer.getData("text/plain");
if (!draggedId || draggedId === targetId) {
handleDragEnd();
return;
}
const fromIndex = subtasks.findIndex((s) => s.id === draggedId);
const toIndex = subtasks.findIndex((s) => s.id === targetId);
if (fromIndex === -1 || toIndex === -1) {
handleDragEnd();
return;
}
const newSubtasks = [...subtasks];
const [moved] = newSubtasks.splice(fromIndex, 1);
let insertIndex = toIndex;
if (dragOverPosition === "after" && fromIndex < toIndex) insertIndex--;
if (dragOverPosition === "after") insertIndex++;
newSubtasks.splice(insertIndex, 0, moved);
onUpdateSubtasks(newSubtasks);
handleDragEnd();
}, [subtasks, dragOverPosition, onUpdateSubtasks, handleDragEnd]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setDragOverId(null);
setDragOverPosition(null);
}
}, []);
return (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
<div className="planning-summary-header">
<ListTree size={24} style={{ color: "var(--triage)" }} />
<h4>Break into Tasks</h4>
<p className="text-muted">
Review and edit the subtasks generated from your plan. Adjust titles,
descriptions, sizes, and dependencies before creating.
</p>
</div>
<div className="planning-summary-form">
{subtasks.map((subtask, index) => {
const isDragging = draggingId === subtask.id;
const isDragOver = dragOverId === subtask.id;
const dragClasses = [
"task-detail-section",
"subtask-item",
isDragging ? "subtask-item-dragging" : "",
isDragOver ? "subtask-item-drop-target" : "",
isDragOver && dragOverPosition === "before" ? "subtask-item-drop-before" : "",
isDragOver && dragOverPosition === "after" ? "subtask-item-drop-after" : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={subtask.id}
className={dragClasses}
data-testid={`subtask-item-${index}`}
draggable={!isLoading}
onDragStart={handleDragStart(subtask.id)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver(subtask.id)}
onDrop={handleDrop(subtask.id)}
onDragLeave={handleDragLeave}
>
<div
className="detail-title-row subtask-item-header"
style={{ justifyContent: "space-between" }}
>
<div className="subtask-drag-handle" title="Drag to reorder">
<GripVertical size={16} />
<strong>{subtask.id}</strong>
</div>
<div className="subtask-item-actions">
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveSubtask(index, index - 1)}
disabled={isLoading || index === 0}
title="Move up"
aria-label="Move subtask up"
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveSubtask(index, index + 1)}
disabled={isLoading || index === subtasks.length - 1}
title="Move down"
aria-label="Move subtask down"
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => removeSubtask(subtask.id)}
disabled={isLoading}
>
<Trash2 size={14} /> Remove
</button>
</div>
</div>
<div className="form-group">
<label>Title</label>
<input
ref={(element) => {
titleRefs.current[index] = element;
}}
value={subtask.title}
onChange={(event) => updateSubtask(subtask.id, { title: event.target.value })}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (index < subtasks.length - 1) {
titleRefs.current[index + 1]?.focus();
}
}
}}
disabled={isLoading}
/>
</div>
<div className="form-group">
<label>Description</label>
<textarea
rows={3}
value={subtask.description}
onChange={(event) =>
updateSubtask(subtask.id, { description: event.target.value })
}
disabled={isLoading}
/>
</div>
<div className="form-group">
<label>Size</label>
<div className="planning-size-selector">
{(["S", "M", "L"] as const).map((size) => (
<button
key={size}
type="button"
className={`planning-size-btn ${subtask.suggestedSize === size ? "selected" : ""}`}
onClick={() => updateSubtask(subtask.id, { suggestedSize: size })}
disabled={isLoading}
>
{size}
</button>
))}
</div>
</div>
<div className="form-group">
<label>Dependencies</label>
<div className="planning-deps-list">
{subtasks
.slice(0, index)
.filter((item) => item.id !== subtask.id)
.map((candidate) => {
const selected = subtask.dependsOn.includes(candidate.id);
return (
<label
key={candidate.id}
className={`planning-dep-chip ${selected ? "selected" : ""}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => {
const nextDeps = selected
? subtask.dependsOn.filter((dep) => dep !== candidate.id)
: [...subtask.dependsOn, candidate.id];
updateSubtask(subtask.id, { dependsOn: nextDeps });
}}
disabled={isLoading}
/>
<span className="planning-dep-id">{candidate.id}</span>
<span className="planning-dep-title">
{candidate.title || "Untitled"}
</span>
</label>
);
})}
{index === 0 && (
<div className="text-muted">First subtask cannot have dependencies.</div>
)}
{index > 0 &&
subtasks
.slice(0, index)
.filter((item) => item.id !== subtask.id).length === 0 && (
<div className="text-muted">No previous subtasks available.</div>
)}
</div>
</div>
</div>
);
})}
<button type="button" className="btn" onClick={addSubtask} disabled={isLoading}>
<Plus size={16} style={{ marginRight: 6 }} /> Add subtask
</button>
{hasDependencyCycle(subtasks) && (
<div className="form-error planning-error">
Dependencies contain a cycle. Remove circular references before creating tasks.
</div>
)}
</div>
</div>
<div className="planning-actions planning-summary-actions">
<button className="btn" onClick={onBack} disabled={isLoading}>
<ArrowLeft size={16} style={{ marginRight: "4px" }} />
Back to Summary
</button>
<button
className="btn btn-primary"
onClick={onCreateTask}
disabled={isLoading}
onClick={onCreateTasks}
disabled={isLoading || isInvalid}
>
{isLoading ? (
<>
<Loader2 size={16} className="spin" style={{ marginRight: "8px" }} />
<Loader2 size={16} className="spin" style={{ marginRight: 8 }} />
Creating...
</>
) : (
<>
<CheckCircle size={16} style={{ marginRight: "8px" }} />
Create Task
</>
<>Create Tasks</>
)}
</button>
</div>

View File

@@ -10522,6 +10522,12 @@ html .column.drag-over * {
justify-content: space-between;
}
.planning-summary-actions-right {
display: flex;
gap: var(--space-sm);
align-items: center;
}
/* Loading State */
.planning-loading {
display: flex;

View File

@@ -14,6 +14,7 @@ import {
SessionNotFoundError,
InvalidSessionStateError,
parseAgentResponse,
generateSubtasksFromPlanning,
} from "./planning.js";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -372,4 +373,157 @@ describe("planning module", () => {
expect(result.type).toBe("complete");
});
});
describe("generateSubtasksFromPlanning", () => {
/** Helper: create a session and complete it to get a summary */
async function createCompletedSession(
ip: string,
plan: string,
overrides?: Partial<PlanningSummary>
): Promise<string> {
const { sessionId } = await createSession(ip, plan);
// Complete the session by submitting 3 responses
await submitResponse(sessionId, { scope: "medium" });
await submitResponse(sessionId, { requirements: "Test requirements" });
await submitResponse(sessionId, { confirm: true });
return sessionId;
}
it("returns empty array if session not found", () => {
const result = generateSubtasksFromPlanning("non-existent-session-id");
expect(result).toEqual([]);
});
it("returns empty array if session has no summary (not complete)", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, "Incomplete session");
const result = generateSubtasksFromPlanning(sessionId);
expect(result).toEqual([]);
});
it("generates subtasks from keyDeliverables", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Build auth system");
const result = generateSubtasksFromPlanning(sessionId);
// The stubbed session generates 3 key deliverables:
// "Implementation", "Tests", "Documentation"
expect(result.length).toBe(3);
// First subtask has no dependencies
expect(result[0]).toEqual({
id: "subtask-1",
title: "Implementation",
description: expect.any(String),
suggestedSize: "S",
dependsOn: [],
});
// Second subtask depends on first
expect(result[1]).toEqual({
id: "subtask-2",
title: "Tests",
description: expect.any(String),
suggestedSize: "M",
dependsOn: ["subtask-1"],
});
// Third subtask depends on second
expect(result[2]).toEqual({
id: "subtask-3",
title: "Documentation",
description: expect.any(String),
suggestedSize: "S",
dependsOn: ["subtask-2"],
});
});
it("generates fallback subtasks when keyDeliverables is empty", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, "Fallback test");
// Complete the session normally, then manually clear keyDeliverables
await submitResponse(sessionId, { scope: "small" });
await submitResponse(sessionId, { requirements: "test" });
await submitResponse(sessionId, { confirm: true });
// Get the session and manually clear keyDeliverables to test fallback
const session = getSession(sessionId);
expect(session).toBeDefined();
if (session?.summary) {
session.summary.keyDeliverables = [];
}
const result = generateSubtasksFromPlanning(sessionId);
expect(result.length).toBe(3);
expect(result[0]).toEqual({
id: "subtask-1",
title: "Define implementation approach",
description: expect.any(String),
suggestedSize: "S",
dependsOn: [],
});
expect(result[1]).toEqual({
id: "subtask-2",
title: "Implement core changes",
description: expect.any(String),
suggestedSize: "M",
dependsOn: ["subtask-1"],
});
expect(result[2]).toEqual({
id: "subtask-3",
title: "Verify and polish",
description: expect.any(String),
suggestedSize: "S",
dependsOn: ["subtask-2"],
});
});
it("assigns correct sizes based on deliverable position", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, "Multi-deliverable test");
// Complete the session
await submitResponse(sessionId, { scope: "large" });
await submitResponse(sessionId, { requirements: "many things" });
await submitResponse(sessionId, { confirm: true });
// Modify to have 5 deliverables for size variety
const session = getSession(sessionId);
if (session?.summary) {
session.summary.keyDeliverables = [
"Setup project structure",
"Build feature A",
"Build feature B",
"Build feature C",
"Integration tests",
];
}
const result = generateSubtasksFromPlanning(sessionId);
expect(result.length).toBe(5);
// First: S, Middle: M, Last: S
expect(result[0]?.suggestedSize).toBe("S");
expect(result[1]?.suggestedSize).toBe("M");
expect(result[2]?.suggestedSize).toBe("M");
expect(result[3]?.suggestedSize).toBe("M");
expect(result[4]?.suggestedSize).toBe("S");
});
it("uses sequential dependencies between subtasks", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Dependency test");
const result = generateSubtasksFromPlanning(sessionId);
// Each subtask depends on the previous one
for (let i = 1; i < result.length; i++) {
expect(result[i]?.dependsOn).toEqual([`subtask-${i}`]);
}
});
});
});

View File

@@ -19,6 +19,7 @@ import type {
PlanningResponse,
TaskStore,
} from "@fusion/core";
import type { SubtaskItem } from "./subtask-breakdown.js";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
@@ -1164,6 +1165,61 @@ export function getSummary(sessionId: string): PlanningSummary | undefined {
return sessions.get(sessionId)?.summary;
}
/**
* Generate subtasks from a completed planning summary.
* Uses the planning session's summary to create a SubtaskItem[] for multi-task creation.
*
* @param sessionId - The planning session ID
* @returns Array of SubtaskItem with titles derived from keyDeliverables, or fallback
*/
export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
const session = sessions.get(sessionId);
if (!session) return [];
if (!session.summary) return [];
const { summary } = session;
// If key deliverables exist, create one subtask per deliverable
if (summary.keyDeliverables.length > 0) {
return summary.keyDeliverables.map((deliverable, index) => {
const id = `subtask-${index + 1}`;
const dependsOn = index > 0 ? [`subtask-${index}`] : [] as string[];
return {
id,
title: deliverable,
description: summary.description,
suggestedSize: index === 0 ? "S" as const : index === summary.keyDeliverables.length - 1 ? "S" as const : "M" as const,
dependsOn,
};
});
}
// Fallback: 3 subtasks
return [
{
id: "subtask-1",
title: "Define implementation approach",
description: summary.description,
suggestedSize: "S" as const,
dependsOn: [],
},
{
id: "subtask-2",
title: "Implement core changes",
description: summary.description,
suggestedSize: "M" as const,
dependsOn: ["subtask-1"],
},
{
id: "subtask-3",
title: "Verify and polish",
description: summary.description,
suggestedSize: "S" as const,
dependsOn: ["subtask-2"],
},
];
}
/**
* Cleanup a session (used after task creation).
*/

View File

@@ -1257,6 +1257,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
"POST /planning/respond",
"POST /planning/cancel",
"POST /planning/create-task",
"POST /planning/start-breakdown",
"POST /planning/create-tasks",
"GET /planning/:sessionId/stream",
];
console.debug("[planning:routes:registered]", planningRoutes);
@@ -5275,6 +5277,147 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* POST /api/planning/start-breakdown
* Start subtask breakdown from a completed planning session.
* Body: { sessionId: string }
* Returns: { sessionId: string } — ID of the generated subtask breakdown
*/
router.post("/planning/start-breakdown", async (req, res) => {
try {
const { sessionId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
const { getSession, generateSubtasksFromPlanning } = await import("./planning.js");
const session = getSession(sessionId);
if (!session) {
res.status(404).json({ error: `Planning session ${sessionId} not found or expired` });
return;
}
if (!session.summary) {
res.status(400).json({ error: "Planning session is not complete" });
return;
}
const subtasks = generateSubtasksFromPlanning(sessionId);
if (subtasks.length === 0) {
res.status(400).json({ error: "Could not generate subtasks from planning session" });
return;
}
// Return a synthetic session ID (based on the planning session) and the generated subtasks
// We use the planning session ID directly as the breakdown session ID
res.json({ sessionId, subtasks });
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to start planning breakdown" });
}
});
/**
* POST /api/planning/create-tasks
* Create multiple tasks from a completed planning session (after optional editing).
* Body: { planningSessionId: string, subtasks: Array<{id, title, description, suggestedSize, dependsOn}> }
* Returns: { tasks: Task[] }
*/
router.post("/planning/create-tasks", async (req, res) => {
try {
const { planningSessionId, subtasks } = req.body as {
planningSessionId?: string;
subtasks?: Array<{
id: string;
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
dependsOn: string[];
}>;
};
if (!planningSessionId || typeof planningSessionId !== "string") {
res.status(400).json({ error: "planningSessionId is required" });
return;
}
if (!Array.isArray(subtasks) || subtasks.length === 0) {
res.status(400).json({ error: "subtasks must be a non-empty array" });
return;
}
const { getSession, cleanupSession } = await import("./planning.js");
const session = getSession(planningSessionId);
if (!session) {
res.status(404).json({ error: `Planning session ${planningSessionId} not found or expired` });
return;
}
if (!session.summary) {
res.status(400).json({ error: "Planning session is not complete" });
return;
}
// Validate each subtask
for (const item of subtasks) {
if (!item || typeof item.id !== "string" || typeof item.title !== "string" || !item.title.trim()) {
res.status(400).json({ error: "Each subtask must include id and title" });
return;
}
}
const createdTasks = [] as Awaited<ReturnType<typeof store.createTask>>[];
const tempIdToTaskId = new Map<string, string>();
// Create tasks
for (const item of subtasks) {
const task = await store.createTask({
title: item.title.trim(),
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
column: "triage",
dependencies: undefined,
});
tempIdToTaskId.set(item.id, task.id);
createdTasks.push(task);
if (item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L") {
await store.updateTask(task.id, { size: item.suggestedSize });
}
}
// Resolve dependencies
for (let index = 0; index < subtasks.length; index++) {
const item = subtasks[index]!;
const created = createdTasks[index]!;
const resolvedDependencies = Array.isArray(item.dependsOn)
? item.dependsOn.map((dep) => tempIdToTaskId.get(dep)).filter((dep): dep is string => Boolean(dep))
: [];
if (resolvedDependencies.length > 0) {
const updated = await store.updateTask(created.id, { dependencies: resolvedDependencies });
createdTasks[index] = updated;
}
await store.logEntry(
created.id,
"Created via Planning Mode (multi-task)",
`Source: ${session.initialPlan.slice(0, 200)}`
);
}
// Cleanup the planning session
cleanupSession(planningSessionId);
res.status(201).json({ tasks: createdTasks });
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to create tasks from planning" });
}
});
/**
* GET /api/planning/:sessionId/stream
* SSE endpoint for real-time planning session updates.