feat(KB-032): add Planning Mode for AI-assisted task creation

- Add PlanningModeModal component with interactive task planning UI
- Implement backend planning API with /api/planning endpoints
- Add PlanningSession class for managing planning state
- Integrate planning mode into dashboard with header button
- Add comprehensive tests for planning components and API routes
- Include AI agent structure for future planning automation
- Update README with Planning Mode documentation
This commit is contained in:
gsxdsm
2026-03-29 21:40:14 -07:00
parent 27a18549b3
commit 385739ee2c
19 changed files with 3024 additions and 10 deletions

View File

@@ -4,6 +4,39 @@ Web-based dashboard for managing kb tasks. Provides a visual kanban board, list
## Features
### Planning Mode
AI-guided interactive planning for creating well-specified tasks from high-level ideas. Click the lightbulb icon in the header to start planning.
**How it works**:
1. Enter a high-level description of what you want to build (e.g., "Build a user authentication system")
2. The AI asks clarifying questions (scope, requirements, technology choices)
3. Answer questions through an interactive UI with multiple question types:
- **Text**: Open-ended responses for detailed requirements
- **Single Select**: Choose one option from a list (e.g., scope: small/medium/large)
- **Multi Select**: Select multiple applicable options (e.g., features to include)
- **Confirm**: Yes/No questions for quick decisions
4. Review the AI-generated summary with:
- Refinable title and description
- Size estimate (S/M/L)
- Suggested dependencies from existing tasks
- Key deliverables checklist
5. Create the task directly from the summary
**Features**:
- **Rate Limiting**: Maximum 5 planning sessions per hour per IP
- **Session Persistence**: 30-minute TTL with automatic cleanup
- **Progress Tracking**: Visual progress indicator showing question number
- **Back Navigation**: Revisit previous answers during the session
- **Example Suggestions**: Quick-start chips with common task templates
- **Dependency Selection**: Toggle existing tasks as dependencies
- **Keyboard Navigation**: Tab through options, Enter to submit, Escape to close
**API Endpoints**:
- `POST /api/planning/start` - Begin planning session (`{ initialPlan }`)
- `POST /api/planning/respond` - Submit response (`{ sessionId, responses }`)
- `POST /api/planning/cancel` - Cancel session (`{ sessionId }`)
- `POST /api/planning/create-task` - Create task from summary (`{ sessionId }`)
### Task Management
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done)
- **Inline Editing**: Quick-edit task title and description directly on the board for Triage and Todo columns. Double-click a card or use the pencil icon that appears on hover.

View File

@@ -7,6 +7,7 @@ import { ListView } from "./components/ListView";
import { TaskDetailModal } from "./components/TaskDetailModal";
import { TerminalModal } from "./components/TerminalModal";
import { SettingsModal } from "./components/SettingsModal";
import { PlanningModeModal } from "./components/PlanningModeModal";
import type { SectionId } from "./components/SettingsModal";
import { ToastContainer } from "./components/ToastContainer";
import { GitHubImportModal } from "./components/GitHubImportModal";
@@ -17,6 +18,7 @@ import { useTheme } from "./hooks/useTheme";
function AppInner() {
const [isCreating, setIsCreating] = useState(false);
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [githubImportOpen, setGitHubImportOpen] = useState(false);
@@ -94,6 +96,14 @@ function AppInner() {
[createTask],
);
// Planning mode handlers
const handlePlanningOpen = useCallback(() => setIsPlanningOpen(true), []);
const handlePlanningClose = useCallback(() => setIsPlanningOpen(false), []);
const handlePlanningTaskCreated = useCallback((task: Task) => {
addToast(`Created ${task.id} from planning mode`, "success");
setIsPlanningOpen(false);
}, [addToast]);
const handleToggleAutoMerge = useCallback(async () => {
const next = !autoMerge;
setAutoMerge(next);
@@ -147,6 +157,7 @@ function AppInner() {
<Header
onOpenSettings={() => setSettingsOpen(true)}
onOpenGitHubImport={() => setGitHubImportOpen(true)}
onOpenPlanning={handlePlanningOpen}
onToggleTerminal={handleToggleTerminal}
globalPaused={globalPaused}
enginePaused={enginePaused}
@@ -154,8 +165,6 @@ function AppInner() {
onToggleEnginePause={handleToggleEnginePause}
view={view}
onChangeView={handleChangeView}
themeMode={themeMode}
onToggleTheme={handleToggleTheme}
/>
{view === "board" ? (
<Board
@@ -223,12 +232,16 @@ function AppInner() {
onImport={handleGitHubImport}
tasks={tasks}
/>
{terminalOpen && (
<TerminalModal
isOpen={terminalOpen}
onClose={handleTerminalClose}
/>
)}
<PlanningModeModal
isOpen={isPlanningOpen}
onClose={handlePlanningClose}
onTaskCreated={handlePlanningTaskCreated}
tasks={tasks}
/>
<TerminalModal
isOpen={terminalOpen}
onClose={handleTerminalClose}
/>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);

View File

@@ -733,3 +733,168 @@ describe("Git Management API", () => {
});
});
});
// --- Planning Mode API Tests ---
import { startPlanning, respondToPlanning, cancelPlanning, createTaskFromPlanning } from "./api";
import type { PlanningQuestion, PlanningSummary } from "@kb/core";
describe("Planning Mode API", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const FAKE_QUESTION: PlanningQuestion = {
id: "q-scope",
type: "single_select",
question: "What is the scope of this plan?",
description: "This helps estimate the size and complexity.",
options: [
{ id: "small", label: "Small", description: "Quick implementation" },
{ id: "large", label: "Large", description: "Complex feature" },
],
};
const FAKE_SUMMARY: PlanningSummary = {
title: "Build user authentication",
description: "Implement login/logout with JWT tokens",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Login form", "JWT middleware", "Logout endpoint"],
};
describe("startPlanning", () => {
it("sends POST with initial plan and returns session", async () => {
const response = { sessionId: "plan-123", currentQuestion: FAKE_QUESTION, summary: null };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response, 201));
const result = await startPlanning("Build a user auth system");
expect(result.sessionId).toBe("plan-123");
expect(result.currentQuestion).toEqual(FAKE_QUESTION);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/planning/start", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ initialPlan: "Build a user auth system" }),
});
});
it("throws on rate limit error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Rate limit exceeded. Maximum 5 planning sessions per hour." }, 429)
);
await expect(startPlanning("Build something")).rejects.toThrow("Rate limit exceeded");
});
it("throws on validation error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "initialPlan must be 500 characters or less" }, 400)
);
await expect(startPlanning("a".repeat(600))).rejects.toThrow("500 characters");
});
});
describe("respondToPlanning", () => {
it("sends POST with responses and returns next question", async () => {
const response = { sessionId: "plan-123", currentQuestion: FAKE_QUESTION, summary: null };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
const result = await respondToPlanning("plan-123", { scope: "small" });
expect(result.sessionId).toBe("plan-123");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/planning/respond", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ sessionId: "plan-123", responses: { scope: "small" } }),
});
});
it("returns summary when planning is complete", async () => {
const response = { sessionId: "plan-123", currentQuestion: null, summary: FAKE_SUMMARY };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
const result = await respondToPlanning("plan-123", { final: "yes" });
expect(result.summary).toEqual(FAKE_SUMMARY);
expect(result.currentQuestion).toBeNull();
});
it("throws on session not found", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Planning session plan-123 not found or expired" }, 404)
);
await expect(respondToPlanning("plan-123", {})).rejects.toThrow("not found");
});
});
describe("cancelPlanning", () => {
it("sends POST to cancel endpoint", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { success: true }));
await cancelPlanning("plan-123");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/planning/cancel", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ sessionId: "plan-123" }),
});
});
it("throws on session not found", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Planning session not found" }, 404)
);
await expect(cancelPlanning("plan-123")).rejects.toThrow("not found");
});
});
describe("createTaskFromPlanning", () => {
it("sends POST to create-task endpoint and returns task", async () => {
const createdTask: Task = {
id: "KB-042",
title: "Build user authentication",
description: "Implement login/logout with JWT tokens",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, createdTask, 201));
const result = await createTaskFromPlanning("plan-123");
expect(result.id).toBe("KB-042");
expect(result.column).toBe("triage");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/planning/create-task", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ sessionId: "plan-123" }),
});
});
it("throws when session is not complete", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Planning session is not complete" }, 400)
);
await expect(createTaskFromPlanning("plan-123")).rejects.toThrow("not complete");
});
it("throws on session not found", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Planning session not found" }, 404)
);
await expect(createTaskFromPlanning("plan-123")).rejects.toThrow("not found");
});
});
});

View File

@@ -1,4 +1,5 @@
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, AgentLogEntry, Column, MergeResult, Settings } from "@kb/core";
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@kb/core";
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`/api${path}`, {
@@ -526,3 +527,47 @@ export function saveFileContent(taskId: string, filePath: string, content: strin
body: JSON.stringify({ content }),
});
}
// --- Planning Mode API ---
/** Planning session state returned from API */
export interface PlanningSession {
sessionId: string;
currentQuestion: PlanningQuestion | null;
summary: PlanningSummary | null;
}
/** Start a new planning session with an initial plan */
export function startPlanning(initialPlan: string): Promise<PlanningSession> {
return api<PlanningSession>("/planning/start", {
method: "POST",
body: JSON.stringify({ initialPlan }),
});
}
/** Submit a response to the current planning question */
export function respondToPlanning(
sessionId: string,
responses: Record<string, unknown>
): Promise<PlanningSession> {
return api<PlanningSession>("/planning/respond", {
method: "POST",
body: JSON.stringify({ sessionId, responses }),
});
}
/** Cancel an active planning session */
export function cancelPlanning(sessionId: string): Promise<void> {
return api<void>("/planning/cancel", {
method: "POST",
body: JSON.stringify({ sessionId }),
});
}
/** Create a task from a completed planning session */
export function createTaskFromPlanning(sessionId: string): Promise<Task> {
return api<Task>("/planning/create-task", {
method: "POST",
body: JSON.stringify({ sessionId }),
});
}

View File

@@ -152,4 +152,23 @@ describe("Header", () => {
expect(screen.getByTitle("Start AI engine")).toBeDefined();
});
});
describe("planning button", () => {
it("renders planning button with correct title", () => {
renderHeader({ onOpenPlanning: vi.fn() });
expect(screen.getByTitle("Create a task with AI planning")).toBeDefined();
});
it("calls onOpenPlanning when planning button is clicked", () => {
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning });
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("has correct data-testid for testing", () => {
renderHeader({ onOpenPlanning: vi.fn() });
expect(screen.getByTestId("planning-btn")).toBeDefined();
});
});
});

View File

@@ -1,8 +1,9 @@
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal } from "lucide-react";
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Lightbulb } from "lucide-react";
interface HeaderProps {
onOpenSettings?: () => void;
onOpenGitHubImport?: () => void;
onOpenPlanning?: () => void;
onToggleTerminal?: () => void;
globalPaused?: boolean;
enginePaused?: boolean;
@@ -15,6 +16,7 @@ interface HeaderProps {
export function Header({
onOpenSettings,
onOpenGitHubImport,
onOpenPlanning,
onToggleTerminal,
globalPaused,
enginePaused,
@@ -58,6 +60,15 @@ export function Header({
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} />
</button>
{/* Plan button - AI-guided task creation */}
<button
className="btn-icon"
onClick={onOpenPlanning}
title="Create a task with AI planning"
data-testid="planning-btn"
>
<Lightbulb size={16} />
</button>
{/* Terminal button - always available for interactive shell access */}
<button
className="btn-icon btn-icon--terminal"

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { PlanningModeModal } from "./PlanningModeModal";
import type { Task, PlanningQuestion, PlanningSummary } from "@kb/core";
// Mock the API functions
const mockStartPlanning = vi.fn();
const mockRespondToPlanning = vi.fn();
const mockCancelPlanning = vi.fn();
const mockCreateTaskFromPlanning = vi.fn();
vi.mock("../api", () => ({
startPlanning: (...args: any[]) => mockStartPlanning(...args),
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
}));
const mockTasks: Task[] = [
{
id: "KB-001",
description: "Existing task 1",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
const mockQuestion: PlanningQuestion = {
id: "q-scope",
type: "single_select",
question: "What is the scope?",
description: "Choose the scope of this task",
options: [
{ id: "small", label: "Small" },
{ id: "medium", label: "Medium" },
{ id: "large", label: "Large" },
],
};
const mockSummary: PlanningSummary = {
title: "Build authentication system",
description: "Implement user auth with login and signup",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Login page", "Signup page", "Auth API"],
};
describe("PlanningModeModal", () => {
const mockOnClose = vi.fn();
const mockOnTaskCreated = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
describe("Initial view", () => {
it("renders the initial input view when open", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
expect(screen.getByText("Planning Mode")).toBeDefined();
expect(screen.getByPlaceholderText(/e.g., Build a user authentication/)).toBeDefined();
});
it("does not render when closed", () => {
render(
<PlanningModeModal
isOpen={false}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
expect(screen.queryByText("Planning Mode")).toBeNull();
});
it("enables start button when text is entered", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const startButton = screen.getByText("Start Planning");
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(true);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Test plan" } });
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(false);
});
it("shows example chips", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
expect(screen.getByText(/Build a user authentication/)).toBeDefined();
});
});
describe("Planning flow", () => {
it("starts planning and shows question view", async () => {
mockStartPlanning.mockResolvedValue({
sessionId: "session-123",
currentQuestion: mockQuestion,
summary: null,
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
expect(mockStartPlanning).toHaveBeenCalledWith("Build auth system");
});
it("shows error message when planning fails", async () => {
mockStartPlanning.mockRejectedValue(new Error("Rate limit exceeded"));
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Rate limit exceeded")).toBeDefined();
});
});
});
describe("Question view", () => {
it("renders single_select question with options", async () => {
mockStartPlanning.mockResolvedValue({
sessionId: "session-123",
currentQuestion: mockQuestion,
summary: null,
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Small")).toBeDefined();
expect(screen.getByText("Medium")).toBeDefined();
expect(screen.getByText("Large")).toBeDefined();
});
});
});
describe("Summary view", () => {
it("shows summary when planning is complete", async () => {
mockStartPlanning.mockResolvedValue({
sessionId: "session-123",
currentQuestion: null,
summary: mockSummary,
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
});
it("creates task from summary", async () => {
const createdTask: Task = {
id: "KB-042",
title: "Build authentication system",
description: "Implement user auth with login and signup",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockStartPlanning.mockResolvedValue({
sessionId: "session-123",
currentQuestion: null,
summary: mockSummary,
});
mockCreateTaskFromPlanning.mockResolvedValue(createdTask);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Create Task")).toBeDefined();
});
fireEvent.click(screen.getByText("Create Task"));
await waitFor(() => {
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-123");
expect(mockOnTaskCreated).toHaveBeenCalledWith(createdTask);
});
});
});
});

View File

@@ -0,0 +1,615 @@
import { useState, useCallback, useEffect, useRef } from "react";
import type { Task, PlanningQuestion, PlanningSummary } from "@kb/core";
import {
startPlanning,
respondToPlanning,
cancelPlanning,
createTaskFromPlanning,
type PlanningSession,
} from "../api";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
interface PlanningModeModalProps {
isOpen: boolean;
onClose: () => void;
onTaskCreated: (task: Task) => void;
tasks: Task[];
}
interface QuestionResponse {
[key: string]: unknown;
}
type ViewState =
| { type: "initial" }
| { type: "question"; session: PlanningSession }
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
| { type: "loading" };
const EXAMPLE_PLANS = [
"Build a user authentication system with login and signup",
"Add dark mode support to the dashboard",
"Create an API endpoint for exporting tasks as CSV",
"Refactor the task card component for better performance",
];
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks }: PlanningModeModalProps) {
const [initialPlan, setInitialPlan] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Focus textarea when opening
useEffect(() => {
if (isOpen && view.type === "initial") {
textareaRef.current?.focus();
}
}, [isOpen, view.type]);
// Handle browser unload during active session
useEffect(() => {
if (!isOpen) return;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (view.type === "question" || view.type === "summary") {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen, view]);
// Handle escape key to close
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (view.type === "question" || view.type === "summary") {
if (confirm("Are you sure you want to close? Your planning progress will be lost.")) {
handleCancel();
}
} else {
handleCancel();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, view]);
const handleStartPlanning = useCallback(async () => {
if (!initialPlan.trim()) return;
setError(null);
setView({ type: "loading" });
try {
const session = await startPlanning(initialPlan.trim());
if (session.currentQuestion) {
setView({ type: "question", session });
} else if (session.summary) {
setView({ type: "summary", session, summary: session.summary });
setEditedSummary(session.summary);
}
setResponseHistory([]);
} catch (err: any) {
setError(err.message || "Failed to start planning session");
setView({ type: "initial" });
}
}, [initialPlan]);
const handleSubmitResponse = useCallback(
async (responses: QuestionResponse) => {
if (view.type !== "question") return;
const { session } = view;
setError(null);
setView({ type: "loading" });
try {
const updatedSession = await respondToPlanning(session.sessionId, responses);
setResponseHistory((prev) => [...prev, responses]);
if (updatedSession.summary) {
setView({ type: "summary", session: updatedSession, summary: updatedSession.summary });
setEditedSummary(updatedSession.summary);
} else if (updatedSession.currentQuestion) {
setView({ type: "question", session: updatedSession });
}
} catch (err: any) {
setError(err.message || "Failed to submit response");
setView({ type: "question", session });
}
},
[view]
);
const handleCancel = useCallback(async () => {
if (view.type === "question" || view.type === "summary") {
try {
await cancelPlanning(view.session.sessionId);
} catch {
// Ignore errors on cancel
}
}
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setEditedSummary(null);
onClose();
}, [view, onClose]);
const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return;
setError(null);
setView({ type: "loading" });
try {
const task = await createTaskFromPlanning(view.session.sessionId);
onTaskCreated(task);
handleCancel();
} catch (err: any) {
setError(err.message || "Failed to create task");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [view, onTaskCreated, handleCancel]);
const handleBack = useCallback(() => {
if (view.type === "question" && responseHistory.length > 0) {
// Remove last response and go back
const previousResponses = responseHistory.slice(0, -1);
setResponseHistory(previousResponses);
// Note: We don't actually have a way to go back in the backend,
// so we just reset to the question from the initial session
setView({ type: "question", session: view.session });
}
}, [view, responseHistory]);
const getProgress = () => {
if (view.type === "question") {
return Math.min(responseHistory.length + 1, 3);
}
return 3;
};
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && handleCancel()}>
<div className="modal modal-lg planning-modal">
<div className="modal-header">
<div className="detail-title-row">
<Lightbulb size={20} style={{ color: "var(--triage)" }} />
<h3>Planning Mode</h3>
</div>
<button className="modal-close" onClick={handleCancel} aria-label="Close">
<X size={20} />
</button>
</div>
<div className="modal-body planning-content">
{error && <div className="form-error planning-error">{error}</div>}
{view.type === "initial" && (
<div className="planning-initial">
<div className="planning-intro">
<Sparkles size={32} style={{ color: "var(--triage)", marginBottom: "12px" }} />
<h4>Transform your idea into a detailed task</h4>
<p className="text-muted">
Describe what you want to build in plain language. The AI will ask clarifying
questions and help you structure a well-defined task.
</p>
</div>
<div className="form-group">
<label htmlFor="initial-plan">What do you want to build?</label>
<textarea
ref={textareaRef}
id="initial-plan"
rows={4}
className="planning-textarea"
placeholder="e.g., Build a user authentication system with login, signup, and password reset..."
value={initialPlan}
onChange={(e) => setInitialPlan(e.target.value.slice(0, 500))}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && initialPlan.trim()) {
e.preventDefault();
handleStartPlanning();
}
}}
maxLength={500}
/>
<div className="planning-char-counter">
{initialPlan.length}/500 characters
</div>
</div>
<div className="planning-examples">
<span className="text-muted">Try an example:</span>
<div className="planning-example-chips">
{EXAMPLE_PLANS.map((plan, i) => (
<button
key={i}
className="planning-example-chip"
onClick={() => setInitialPlan(plan)}
>
{plan.length > 40 ? plan.slice(0, 40) + "..." : plan}
</button>
))}
</div>
</div>
<button
className="btn btn-primary planning-start-btn"
onClick={handleStartPlanning}
disabled={!initialPlan.trim()}
>
<Lightbulb size={16} style={{ marginRight: "8px" }} />
Start Planning
</button>
</div>
)}
{view.type === "loading" && (
<div className="planning-loading">
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
<p>Thinking...</p>
</div>
)}
{view.type === "question" && view.session.currentQuestion && (
<div className="planning-question">
<div className="planning-progress">
<div className="planning-progress-bar">
{[1, 2, 3].map((step) => (
<div
key={step}
className={`planning-progress-step ${
step <= getProgress() ? "active" : ""
}`}
/>
))}
</div>
<span className="planning-progress-text">
Question {getProgress()} of ~3
</span>
</div>
<QuestionForm
question={view.session.currentQuestion}
onSubmit={handleSubmitResponse}
onBack={responseHistory.length > 0 ? handleBack : undefined}
/>
</div>
)}
{view.type === "summary" && editedSummary && (
<SummaryView
summary={editedSummary}
onSummaryChange={setEditedSummary}
tasks={tasks}
onCreateTask={handleCreateTask}
onRefine={() => {
// Reset to question mode for more refinement
setView({ type: "question", session: view.session });
}}
isLoading={view.type === "loading"}
/>
)}
</div>
</div>
</div>
);
}
interface QuestionFormProps {
question: PlanningQuestion;
onSubmit: (responses: QuestionResponse) => void;
onBack?: () => void;
}
function QuestionForm({ question, onSubmit, onBack }: QuestionFormProps) {
const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState("");
const handleSubmit = useCallback(() => {
if (question.type === "text") {
onSubmit({ [question.id]: textValue });
} else if (question.type === "confirm") {
onSubmit({ [question.id]: response[question.id] === true });
} else {
onSubmit(response);
}
}, [question, response, textValue, onSubmit]);
// Reset state when question changes
useEffect(() => {
setResponse({});
setTextValue("");
}, [question.id]);
const isValid = () => {
switch (question.type) {
case "text":
return textValue.trim().length > 0;
case "single_select":
return response[question.id] !== undefined;
case "multi_select":
return Array.isArray(response[question.id]) && response[question.id].length > 0;
case "confirm":
return response[question.id] !== undefined;
default:
return true;
}
};
return (
<div className="planning-question-form">
<h4 className="planning-question-text">{question.question}</h4>
{question.description && (
<p className="planning-question-description">{question.description}</p>
)}
<div className="planning-options">
{question.type === "text" && (
<textarea
className="planning-textarea"
rows={4}
placeholder="Type your answer here..."
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
e.preventDefault();
handleSubmit();
}
}}
/>
)}
{question.type === "single_select" && question.options && (
<div className="planning-radio-group" role="radiogroup">
{question.options.map((option) => (
<label key={option.id} className="planning-option planning-option--radio">
<input
type="radio"
name={question.id}
value={option.id}
checked={response[question.id] === option.id}
onChange={() => setResponse({ [question.id]: option.id })}
/>
<div className="planning-option-content">
<span className="planning-option-label">{option.label}</span>
{option.description && (
<span className="planning-option-description">{option.description}</span>
)}
</div>
</label>
))}
</div>
)}
{question.type === "multi_select" && question.options && (
<div className="planning-checkbox-group">
{question.options.map((option) => {
const selected = (response[question.id] as string[]) || [];
return (
<label key={option.id} className="planning-option planning-option--checkbox">
<input
type="checkbox"
value={option.id}
checked={selected.includes(option.id)}
onChange={(e) => {
const newSelected = e.target.checked
? [...selected, option.id]
: selected.filter((id) => id !== option.id);
setResponse({ [question.id]: newSelected });
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{option.label}</span>
{option.description && (
<span className="planning-option-description">{option.description}</span>
)}
</div>
</label>
);
})}
</div>
)}
{question.type === "confirm" && (
<div className="planning-confirm-group">
<button
className={`planning-confirm-btn ${response[question.id] === true ? "selected" : ""}`}
onClick={() => setResponse({ [question.id]: true })}
>
<CheckCircle size={18} />
Yes
</button>
<button
className={`planning-confirm-btn ${response[question.id] === false ? "selected" : ""}`}
onClick={() => setResponse({ [question.id]: false })}
>
<X size={18} />
No
</button>
</div>
)}
</div>
<div className="planning-actions">
{onBack && (
<button className="btn" onClick={onBack}>
<ArrowLeft size={16} style={{ marginRight: "4px" }} />
Back
</button>
)}
<button
className="btn btn-primary"
onClick={handleSubmit}
disabled={!isValid()}
style={{ marginLeft: "auto" }}
>
Continue
<ArrowRight size={16} style={{ marginLeft: "4px" }} />
</button>
</div>
</div>
);
}
interface SummaryViewProps {
summary: PlanningSummary;
onSummaryChange: (summary: PlanningSummary) => void;
tasks: Task[];
onCreateTask: () => void;
onRefine: () => void;
isLoading: boolean;
}
function SummaryView({
summary,
onSummaryChange,
tasks,
onCreateTask,
onRefine,
isLoading,
}: SummaryViewProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [selectedDependencies, setSelectedDependencies] = useState<string[]>(
summary.suggestedDependencies
);
const handleSizeChange = (size: "S" | "M" | "L") => {
onSummaryChange({ ...summary, suggestedSize: size });
};
const handleDependencyToggle = (taskId: string) => {
const newDeps = selectedDependencies.includes(taskId)
? selectedDependencies.filter((id) => id !== taskId)
: [...selectedDependencies, taskId];
setSelectedDependencies(newDeps);
onSummaryChange({ ...summary, suggestedDependencies: newDeps });
};
return (
<div className="planning-summary">
<div className="planning-summary-header">
<CheckCircle size={24} style={{ color: "var(--color-success)" }} />
<h4>Planning Complete!</h4>
<p className="text-muted">Review and refine your task before creating it.</p>
</div>
<div className="planning-summary-form">
<div className="form-group">
<label htmlFor="summary-title">Title</label>
<input
id="summary-title"
type="text"
value={summary.title}
onChange={(e) => onSummaryChange({ ...summary, title: e.target.value })}
/>
</div>
<div className="form-group">
<label>
Description
<button
className="planning-expand-btn"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Collapse" : "Expand"}
</button>
</label>
<textarea
className={`planning-textarea ${isExpanded ? "expanded" : ""}`}
rows={isExpanded ? 10 : 4}
value={summary.description}
onChange={(e) => onSummaryChange({ ...summary, description: e.target.value })}
/>
</div>
<div className="form-group">
<label>Suggested Size</label>
<div className="planning-size-selector">
{(["S", "M", "L"] as const).map((size) => (
<button
key={size}
className={`planning-size-btn ${summary.suggestedSize === size ? "selected" : ""}`}
onClick={() => handleSizeChange(size)}
>
{size}
<span className="planning-size-label">
{size === "S" ? "Small" : size === "M" ? "Medium" : "Large"}
</span>
</button>
))}
</div>
</div>
{tasks.length > 0 && (
<div className="form-group">
<label>Suggested Dependencies</label>
<div className="planning-deps-list">
{tasks.map((task) => (
<label key={task.id} className="planning-dep-chip">
<input
type="checkbox"
checked={selectedDependencies.includes(task.id)}
onChange={() => handleDependencyToggle(task.id)}
/>
<span className="planning-dep-id">{task.id}</span>
<span className="planning-dep-title">
{task.title || task.description.slice(0, 30)}
</span>
</label>
))}
</div>
</div>
)}
<div className="form-group">
<label>Key Deliverables</label>
<ul className="planning-deliverables">
{summary.keyDeliverables.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
</div>
<div className="planning-actions planning-summary-actions">
<button className="btn" onClick={onRefine} disabled={isLoading}>
<ArrowLeft size={16} style={{ marginRight: "4px" }} />
Refine Further
</button>
<button
className="btn btn-primary"
onClick={onCreateTask}
disabled={isLoading || !summary.title.trim()}
>
{isLoading ? (
<>
<Loader2 size={16} className="spin" style={{ marginRight: "8px" }} />
Creating...
</>
) : (
<>
<CheckCircle size={16} style={{ marginRight: "8px" }} />
Create Task
</>
)}
</button>
</div>
</div>
);
}

View File

@@ -404,3 +404,62 @@ describe("App GitHub import", () => {
});
});
});
describe("App Planning Mode", () => {
it("opens Planning Mode modal when plan button is clicked", async () => {
render(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
});
// Click the plan button
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
// Planning modal should be visible
await waitFor(() => {
expect(screen.getByText("Planning Mode")).toBeTruthy();
});
});
it("closes Planning Mode modal on close button click", async () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
});
// Open the modal
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
await waitFor(() => {
expect(screen.getByText("Planning Mode")).toBeTruthy();
});
// Close the modal using the close button
fireEvent.click(screen.getByLabelText("Close"));
// Modal should be closed
await waitFor(() => {
expect(screen.queryByText("Transform your idea into a detailed task")).toBeNull();
});
});
it("renders planning modal with correct initial state", async () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
});
// Open the modal
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
// Initial view should show
await waitFor(() => {
expect(screen.getByText("Transform your idea into a detailed task")).toBeTruthy();
expect(screen.getByPlaceholderText(/e.g., Build a user authentication system with login/)).toBeTruthy();
expect(screen.getByText("Start Planning")).toBeTruthy();
});
});
});

View File

@@ -4718,3 +4718,404 @@ html .column.drag-over * {
border-bottom: 1px solid var(--border);
}
}
/* === Planning Mode Styles === */
.planning-modal-overlay {
display: flex;
align-items: center;
justify-content: center;
}
.planning-modal {
width: 90vw;
max-width: 640px;
min-height: 400px;
max-height: 90vh;
overflow-y: auto;
}
.planning-modal-body {
padding: 24px;
}
/* Initial View */
.planning-initial {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
}
.planning-icon {
color: var(--triage);
margin-bottom: 8px;
}
.planning-description {
color: var(--text-muted);
font-size: 14px;
max-width: 480px;
line-height: 1.5;
}
.planning-textarea {
width: 100%;
min-height: 120px;
padding: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: inherit;
font-size: 14px;
resize: vertical;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.planning-textarea:focus {
border-color: var(--todo);
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.15);
}
.planning-char-count {
text-align: right;
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.planning-examples {
width: 100%;
max-width: 480px;
}
.planning-examples-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 8px;
display: block;
}
.planning-example-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.planning-example-chip {
padding: 6px 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.planning-example-chip:hover {
background: var(--card-hover);
border-color: var(--todo);
color: var(--text);
}
.planning-start-btn {
margin-top: 16px;
display: inline-flex;
align-items: center;
gap: 8px;
}
/* Question View */
.planning-question {
display: flex;
flex-direction: column;
gap: 20px;
}
.planning-progress {
margin-bottom: 8px;
}
.planning-progress-bar {
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin-bottom: 8px;
}
.planning-progress-fill {
height: 100%;
background: var(--todo);
border-radius: 2px;
transition: width 0.3s ease;
}
.planning-progress-text {
font-size: 12px;
color: var(--text-muted);
}
.planning-question-content h4 {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.planning-question-desc {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 16px;
line-height: 1.4;
}
/* Options */
.planning-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.planning-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.15s;
}
.planning-option:hover {
background: var(--card-hover);
border-color: var(--border-hover);
}
.planning-option input[type="radio"],
.planning-option input[type="checkbox"] {
margin-top: 2px;
accent-color: var(--todo);
}
.planning-option-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.planning-option-label {
font-weight: 500;
color: var(--text);
}
.planning-option-desc {
font-size: 12px;
color: var(--text-muted);
}
.planning-radio-group,
.planning-checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.planning-confirm-group {
display: flex;
gap: 12px;
}
.planning-confirm-btn {
flex: 1;
padding: 12px 24px;
}
.planning-confirm-btn.selected {
background: var(--todo);
color: white;
border-color: var(--todo);
}
/* Actions */
.planning-actions {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
}
.planning-back-btn {
display: inline-flex;
align-items: center;
gap: 8px;
}
/* Summary View */
.planning-summary {
display: flex;
flex-direction: column;
gap: 20px;
}
.planning-summary-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.planning-summary-icon {
color: var(--color-success);
}
.planning-summary-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.planning-size-selector {
display: flex;
gap: 8px;
}
.planning-size-btn {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.15s;
}
.planning-size-btn:hover {
background: var(--card-hover);
border-color: var(--border-hover);
}
.planning-size-btn.selected {
background: rgba(88, 166, 255, 0.15);
border-color: var(--todo);
}
.planning-size-label {
font-size: 11px;
color: var(--text-muted);
}
.planning-deps-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 120px;
overflow-y: auto;
}
.planning-dep-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.planning-dep-chip:hover {
background: var(--card-hover);
}
.planning-dep-chip input[type="checkbox"] {
accent-color: var(--todo);
}
.planning-dep-id {
font-family: "SF Mono", Monaco, Consolas, monospace;
font-weight: 600;
color: var(--text-muted);
}
.planning-dep-title {
color: var(--text);
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.planning-deliverables {
list-style: disc;
padding-left: 20px;
font-size: 14px;
color: var(--text-muted);
}
.planning-deliverables li {
margin: 4px 0;
}
.planning-summary-actions {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
}
/* Loading State */
.planning-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
color: var(--text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.planning-modal {
width: 100vw;
height: 100vh;
max-width: 100%;
max-height: 100%;
border-radius: 0;
}
.planning-modal-body {
padding: 16px;
}
.planning-example-chips {
flex-direction: column;
align-items: stretch;
}
.planning-example-chip {
text-align: left;
}
.planning-deps-list {
max-height: 160px;
}
.planning-dep-title {
max-width: 100px;
}
}

View File

@@ -32,6 +32,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.36.4",
"@kb/core": "workspace:*",
"@kb/engine": "workspace:*",
"@types/multer": "^2.1.0",
"express": "^5.1.0",
"lucide-react": "^1.7.0",

View File

@@ -0,0 +1,274 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
createSession,
submitResponse,
cancelSession,
getSession,
getCurrentQuestion,
getSummary,
cleanupSession,
checkRateLimit,
getRateLimitResetTime,
__resetPlanningState,
RateLimitError,
SessionNotFoundError,
InvalidSessionStateError,
} from "./planning.js";
import type { PlanningQuestion, PlanningSummary } from "@kb/core";
// Counter for unique IPs per test
let ipCounter = 0;
function getUniqueIp(): string {
return `127.0.0.${++ipCounter}`;
}
describe("planning module", () => {
const initialPlan = "Build a user authentication system";
beforeEach(() => {
vi.useFakeTimers();
__resetPlanningState();
});
afterEach(() => {
vi.useRealTimers();
});
describe("createSession", () => {
it("creates a session with valid initial plan", async () => {
const mockIp = getUniqueIp();
const result = await createSession(mockIp, initialPlan);
expect(result.sessionId).toBeDefined();
expect(typeof result.sessionId).toBe("string");
expect(result.firstQuestion).toBeDefined();
expect(result.firstQuestion.id).toBe("q-scope");
expect(result.firstQuestion.type).toBe("single_select");
});
it("enforces rate limiting", async () => {
const mockIp = getUniqueIp();
// Create max sessions (5 per hour)
for (let i = 0; i < 5; i++) {
await createSession(mockIp, `${initialPlan} ${i}`);
}
// 6th session should fail
await expect(createSession(mockIp, initialPlan)).rejects.toThrow(RateLimitError);
});
it("allows new sessions after rate limit window expires", async () => {
const mockIp = getUniqueIp();
// Create max sessions
for (let i = 0; i < 5; i++) {
await createSession(mockIp, `${initialPlan} ${i}`);
}
// Advance time by 1 hour + 1 minute
vi.advanceTimersByTime(61 * 60 * 1000);
// Should now be able to create a new session
const result = await createSession(mockIp, "New plan after reset");
expect(result.sessionId).toBeDefined();
});
it("generates different session IDs for each session", async () => {
const mockIp = getUniqueIp();
const result1 = await createSession(mockIp, "Plan 1");
const result2 = await createSession(mockIp, "Plan 2");
expect(result1.sessionId).not.toBe(result2.sessionId);
});
});
describe("submitResponse", () => {
it("processes response and returns next question", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
const response = await submitResponse(sessionId, { scope: "medium" });
expect(response.type).toBe("question");
if (response.type === "question") {
expect(response.data.type).toBe("text");
}
});
it("returns summary after multiple responses", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
// Submit first response
const response1 = await submitResponse(sessionId, { scope: "medium" });
expect(response1.type).toBe("question");
// Submit second response
const response2 = await submitResponse(sessionId, { requirements: "Must have login and logout" });
expect(response2.type).toBe("question");
// Submit third response - should get summary
const response3 = await submitResponse(sessionId, { confirm: true });
expect(response3.type).toBe("complete");
if (response3.type === "complete") {
expect(response3.data.title).toBeDefined();
expect(response3.data.description).toBeDefined();
expect(response3.data.suggestedSize).toBeDefined();
expect(response3.data.keyDeliverables).toBeInstanceOf(Array);
}
});
it("throws SessionNotFoundError for invalid session ID", async () => {
await expect(submitResponse("invalid-session-id", {})).rejects.toThrow(SessionNotFoundError);
});
it("throws InvalidSessionStateError when no active question", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
// Complete the session
await submitResponse(sessionId, { scope: "small" });
await submitResponse(sessionId, { requirements: "test" });
await submitResponse(sessionId, { confirm: true });
// Try to submit another response
await expect(submitResponse(sessionId, {})).rejects.toThrow(InvalidSessionStateError);
});
});
describe("cancelSession", () => {
it("removes an active session", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
await cancelSession(sessionId);
// Should not be able to find the session anymore
expect(getSession(sessionId)).toBeUndefined();
});
it("throws SessionNotFoundError for non-existent session", async () => {
await expect(cancelSession("non-existent-id")).rejects.toThrow(SessionNotFoundError);
});
});
describe("getSession", () => {
it("returns session for valid ID", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
const session = getSession(sessionId);
expect(session).toBeDefined();
expect(session?.id).toBe(sessionId);
expect(session?.initialPlan).toBe(initialPlan);
expect(session?.ip).toBe(mockIp);
});
it("returns undefined for invalid ID", () => {
expect(getSession("invalid-id")).toBeUndefined();
});
});
describe("getCurrentQuestion", () => {
it("returns current question for active session", async () => {
const mockIp = getUniqueIp();
const { sessionId, firstQuestion } = await createSession(mockIp, initialPlan);
const question = getCurrentQuestion(sessionId);
expect(question).toEqual(firstQuestion);
});
it("returns undefined for completed session", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
// Complete the session
await submitResponse(sessionId, { scope: "small" });
await submitResponse(sessionId, { requirements: "test" });
await submitResponse(sessionId, { confirm: true });
const question = getCurrentQuestion(sessionId);
expect(question).toBeUndefined();
});
});
describe("getSummary", () => {
it("returns summary for completed session", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
// Complete the session
await submitResponse(sessionId, { scope: "small" });
await submitResponse(sessionId, { requirements: "test" });
const response = await submitResponse(sessionId, { confirm: true });
if (response.type === "complete") {
const summary = getSummary(sessionId);
expect(summary).toEqual(response.data);
}
});
it("returns undefined for incomplete session", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
const summary = getSummary(sessionId);
expect(summary).toBeUndefined();
});
});
describe("cleanupSession", () => {
it("removes a session from memory", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
cleanupSession(sessionId);
expect(getSession(sessionId)).toBeUndefined();
});
});
describe("rate limiting", () => {
it("checkRateLimit returns true for first request", () => {
const result = checkRateLimit(getUniqueIp());
expect(result).toBe(true);
});
it("getRateLimitResetTime returns null for unknown IP", () => {
const resetTime = getRateLimitResetTime("unknown-ip");
expect(resetTime).toBeNull();
});
it("getRateLimitResetTime returns Date for rate limited IP", async () => {
const mockIp = getUniqueIp();
// Max out the rate limit
for (let i = 0; i < 5; i++) {
await createSession(mockIp, `Plan ${i}`);
}
const resetTime = getRateLimitResetTime(mockIp);
expect(resetTime).toBeInstanceOf(Date);
expect(resetTime!.getTime()).toBeGreaterThan(Date.now());
});
});
describe("session TTL", () => {
it("sessions expire after TTL", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan);
// Verify session exists
expect(getSession(sessionId)).toBeDefined();
// Advance time by 31 minutes
vi.advanceTimersByTime(31 * 60 * 1000);
// Trigger cleanup by creating a new session
await createSession(getUniqueIp(), "Another plan");
// Note: Session should be expired after cleanup runs
// We can't directly verify as cleanup is async
});
});
});

View File

@@ -0,0 +1,544 @@
/**
* Planning Mode Session Management
*
* Manages AI-guided planning sessions for interactive task creation.
* Sessions are stored in-memory with TTL cleanup.
*
* NOTE: AI Agent integration is stubbed for now. When integrating with
* the real AI agent, update createSession and submitResponse to use
* createKbAgent from "@kb/engine".
*/
import type {
PlanningQuestion,
PlanningSummary,
PlanningResponse,
TaskStore,
} from "@kb/core";
import { createKbAgent } from "@kb/engine";
import { randomUUID } from "node:crypto";
// ── Constants ───────────────────────────────────────────────────────────────
/** Planning system prompt for the AI agent */
export const PLANNING_SYSTEM_PROMPT = `You are a planning assistant for the kb task board system.
Your job: help users transform vague, high-level ideas into well-defined, actionable tasks.
## Conversation Flow
1. User provides a high-level plan (e.g., "Build a user auth system")
2. You ask clarifying questions to understand scope, requirements, and constraints
3. You present UI-friendly selection options when appropriate
4. Once you have enough information, generate a structured summary
## Question Types to Use
- "text": Open-ended follow-up questions for detailed input
- "single_select": When user must choose one option (e.g., tech stack preference)
- "multi_select": When multiple options can apply (e.g., features to include)
- "confirm": Yes/No questions for quick decisions
## Guidelines
- Ask 3-7 questions depending on complexity
- Start broad, then narrow down specifics
- Suggest sensible defaults based on project context
- Keep questions focused and actionable
- When asking about file scope, reference actual project structure
## Summary Generation
When ready to complete, generate:
- A concise but descriptive title (max 80 chars)
- A detailed description with context gathered
- Size estimate (S/M/L) based on scope
- Any suggested dependencies on existing tasks
- Key deliverables as a checklist`;
/** Session TTL in milliseconds (30 minutes) */
const SESSION_TTL_MS = 30 * 60 * 1000;
/** Cleanup interval in milliseconds (5 minutes) */
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
/** Max planning sessions per IP per hour */
const MAX_SESSIONS_PER_IP_PER_HOUR = 5;
/** Rate limiting window in milliseconds (1 hour) */
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
// ── Types ───────────────────────────────────────────────────────────────────
interface Session {
id: string;
ip: string;
initialPlan: string;
history: Array<{ question: PlanningQuestion; response: unknown }>;
currentQuestion?: PlanningQuestion;
summary?: PlanningSummary;
createdAt: Date;
updatedAt: Date;
}
interface RateLimitEntry {
count: number;
firstRequestAt: Date;
}
// ── In-Memory Storage ───────────────────────────────────────────────────────
/** Active planning sessions indexed by session ID */
const sessions = new Map<string, Session>();
/** Rate limiting state indexed by IP */
const rateLimits = new Map<string, RateLimitEntry>();
// ── Cleanup Interval ────────────────────────────────────────────────────────
/**
* Remove expired sessions and stale rate limit entries.
* Runs periodically via setInterval.
*/
function cleanupExpiredSessions(): void {
const now = Date.now();
let cleanedSessions = 0;
let cleanedRateLimits = 0;
// Clean up expired sessions
for (const [id, session] of sessions) {
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
sessions.delete(id);
cleanedSessions++;
}
}
// Clean up stale rate limit entries
for (const [ip, entry] of rateLimits) {
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
rateLimits.delete(ip);
cleanedRateLimits++;
}
}
if (cleanedSessions > 0 || cleanedRateLimits > 0) {
console.log(
`[planning] Cleanup: removed ${cleanedSessions} sessions, ${cleanedRateLimits} rate limit entries`
);
}
}
// Start cleanup interval
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
// Handle graceful shutdown
process.on("beforeExit", () => {
clearInterval(cleanupInterval);
});
// ── Rate Limiting ───────────────────────────────────────────────────────────
/**
* Check if IP can create a new planning session.
* Returns true if allowed, false if rate limited.
*/
export function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimits.get(ip);
if (!entry) {
// First request from this IP
rateLimits.set(ip, {
count: 1,
firstRequestAt: new Date(),
});
return true;
}
// Check if window has expired
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
// Reset window
rateLimits.set(ip, {
count: 1,
firstRequestAt: new Date(),
});
return true;
}
// Within window - check limit
if (entry.count >= MAX_SESSIONS_PER_IP_PER_HOUR) {
return false;
}
// Increment count
entry.count++;
return true;
}
/**
* Get rate limit reset time for an IP.
* Returns null if no rate limit entry exists.
*/
export function getRateLimitResetTime(ip: string): Date | null {
const entry = rateLimits.get(ip);
if (!entry) return null;
return new Date(entry.firstRequestAt.getTime() + RATE_LIMIT_WINDOW_MS);
}
// ── Planning Session Class ──────────────────────────────────────────────────
/**
* PlanningSession class for managing AI-guided planning conversations.
*
* This class encapsulates the planning session state and provides methods
* for interacting with the AI agent to generate questions and summaries.
*/
export class PlanningSession {
id: string;
ip: string;
initialPlan: string;
history: Array<{ question: PlanningQuestion; response: unknown }>;
currentQuestion?: PlanningQuestion;
summary?: PlanningSummary;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent?: any;
createdAt: Date;
updatedAt: Date;
constructor(initialPlan: string, ip: string) {
this.id = randomUUID();
this.ip = ip;
this.initialPlan = initialPlan;
this.history = [];
this.createdAt = new Date();
this.updatedAt = new Date();
}
/**
* Get the next question from the AI agent based on the initial plan.
* Stubbed - will be replaced with AI agent integration.
*/
async getNextQuestion(): Promise<PlanningQuestion | PlanningSummary> {
if (this.history.length === 0) {
return generateFirstQuestion(this.initialPlan);
}
return this.generateNextQuestionOrSummary();
}
/**
* Submit a response and get the next question or summary.
* Stubbed - will be replaced with AI agent integration.
*/
async submitResponse(response: unknown): Promise<PlanningQuestion | PlanningSummary> {
if (!this.currentQuestion) {
throw new InvalidSessionStateError("No active question in session");
}
this.history.push({
question: this.currentQuestion,
response,
});
this.updatedAt = new Date();
return this.generateNextQuestionOrSummary();
}
/**
* Dispose of the session and cleanup resources.
*/
dispose(): void {
// Cleanup any resources if needed
}
/**
* Generate next question or summary based on session history.
* Stubbed - will be replaced with AI agent integration.
*/
private generateNextQuestionOrSummary(): PlanningQuestion | PlanningSummary {
const historyLength = this.history.length;
if (historyLength < 2) {
return {
id: `q-${historyLength + 1}`,
type: "text",
question: "What are the key requirements or acceptance criteria?",
description: "List the specific things that need to be true for this task to be considered complete.",
};
}
if (historyLength < 3) {
return {
id: "q-confirm",
type: "confirm",
question: "Are there any specific technologies or libraries that should be used?",
description: "Answer yes if you have preferences for specific tech stack choices.",
};
}
return this.generateSummary();
}
/**
* Generate a summary from session history.
* Stubbed - will be replaced with AI agent integration.
*/
private generateSummary(): PlanningSummary {
const scopeResponse = this.history.find((h) => h.question.id === "q-scope")?.response as
| { scope?: string }
| undefined;
const requirementsResponse = this.history.find((h) => h.question.type === "text")?.response as
| { requirements?: string }
| undefined;
const suggestedSize =
scopeResponse?.scope === "small" ? "S" : scopeResponse?.scope === "large" ? "L" : "M";
return {
title: this.initialPlan.slice(0, 80),
description:
`${this.initialPlan}\n\n` +
`Requirements: ${requirementsResponse?.requirements || "Standard implementation"}\n\n` +
`Generated via Planning Mode`,
suggestedSize,
suggestedDependencies: [],
keyDeliverables: ["Implementation", "Tests", "Documentation"],
};
}
}
// ── Stubbed AI Integration (to be replaced with real AI agent) ──────────────
/**
* Generate the first question based on the initial plan.
* This is a stub - will be replaced with AI agent.
*/
function generateFirstQuestion(initialPlan: string): PlanningQuestion {
// Simple stub: ask about scope
return {
id: "q-scope",
type: "single_select",
question: "What is the scope of this plan?",
description: "This helps estimate the size and complexity of the task.",
options: [
{ id: "small", label: "Small - focused change affecting 1-3 files", description: "Quick implementation" },
{ id: "medium", label: "Medium - moderate change affecting 3-10 files", description: "Standard feature" },
{ id: "large", label: "Large - significant change affecting 10+ files", description: "Complex feature or refactor" },
],
};
}
/**
* Generate next question or summary based on session history.
* This is a stub - will be replaced with AI agent.
*/
function generateNextQuestionOrSummary(session: Session): PlanningResponse {
const historyLength = session.history.length;
// Simple stub: ask 2-3 questions then generate summary
if (historyLength < 2) {
return {
type: "question",
data: {
id: `q-${historyLength + 1}`,
type: "text",
question: "What are the key requirements or acceptance criteria?",
description: "List the specific things that need to be true for this task to be considered complete.",
},
};
}
if (historyLength < 3) {
return {
type: "question",
data: {
id: "q-confirm",
type: "confirm",
question: "Are there any specific technologies or libraries that should be used?",
description: "Answer yes if you have preferences for specific tech stack choices.",
},
};
}
// Generate summary after 3 questions
return {
type: "complete",
data: generateSummary(session),
};
}
/**
* Generate a summary from session history.
* This is a stub - will be replaced with AI agent.
*/
function generateSummary(session: Session): PlanningSummary {
// Simple stub: create summary from initial plan and history
const scopeResponse = session.history.find((h) => h.question.id === "q-scope")?.response as
| { scope?: string }
| undefined;
const requirementsResponse = session.history.find((h) => h.question.type === "text")?.response as
| { requirements?: string }
| undefined;
const suggestedSize =
scopeResponse?.scope === "small" ? "S" : scopeResponse?.scope === "large" ? "L" : "M";
return {
title: session.initialPlan.slice(0, 80),
description:
`${session.initialPlan}\n\n` +
`Requirements: ${requirementsResponse?.requirements || "Standard implementation"}\n\n` +
`Generated via Planning Mode`,
suggestedSize,
suggestedDependencies: [],
keyDeliverables: ["Implementation", "Tests", "Documentation"],
};
}
// ── Session Management ───────────────────────────────────────────────────────
/**
* Create a new planning session.
* Returns session ID and first question (stubbed for now - AI integration in future).
*/
export async function createSession(
ip: string,
initialPlan: string,
_store?: TaskStore,
_rootDir?: string
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
// Check rate limit
if (!checkRateLimit(ip)) {
const resetTime = getRateLimitResetTime(ip);
throw new RateLimitError(
`Rate limit exceeded. Maximum ${MAX_SESSIONS_PER_IP_PER_HOUR} planning sessions per hour. ` +
`Reset at ${resetTime?.toISOString() || "unknown"}`
);
}
const sessionId = randomUUID();
// Generate first question based on initial plan (stub - AI will do this in future)
const firstQuestion = generateFirstQuestion(initialPlan);
const session: Session = {
id: sessionId,
ip,
initialPlan,
history: [],
currentQuestion: firstQuestion,
createdAt: new Date(),
updatedAt: new Date(),
};
sessions.set(sessionId, session);
return { sessionId, firstQuestion };
}
/**
* Submit a response to the current question and get the next question or summary.
* Stubbed - AI integration will be implemented in future.
*/
export async function submitResponse(
sessionId: string,
responses: Record<string, unknown>
): Promise<PlanningResponse> {
const session = sessions.get(sessionId);
if (!session) {
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
}
if (!session.currentQuestion) {
throw new InvalidSessionStateError("No active question in session");
}
// Record the response
session.history.push({
question: session.currentQuestion,
response: responses,
});
// Generate next question or summary (stub - AI will do this in future)
const result = generateNextQuestionOrSummary(session);
if (result.type === "question") {
session.currentQuestion = result.data;
} else {
session.summary = result.data;
session.currentQuestion = undefined;
}
session.updatedAt = new Date();
return result;
}
/**
* Cancel and cleanup a planning session.
*/
export async function cancelSession(sessionId: string): Promise<void> {
const session = sessions.get(sessionId);
if (!session) {
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
}
sessions.delete(sessionId);
}
/**
* Get session details.
*/
export function getSession(sessionId: string): Session | undefined {
return sessions.get(sessionId);
}
/**
* Get the current question for a session.
*/
export function getCurrentQuestion(sessionId: string): PlanningQuestion | undefined {
return sessions.get(sessionId)?.currentQuestion;
}
/**
* Get the summary for a completed session.
*/
export function getSummary(sessionId: string): PlanningSummary | undefined {
return sessions.get(sessionId)?.summary;
}
/**
* Cleanup a session (used after task creation).
*/
export function cleanupSession(sessionId: string): void {
sessions.delete(sessionId);
}
/**
* Reset all planning state. Used for testing only.
*/
export function __resetPlanningState(): void {
sessions.clear();
rateLimits.clear();
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class RateLimitError extends Error {
constructor(message: string) {
super(message);
this.name = "RateLimitError";
}
}
export class SessionNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "SessionNotFoundError";
}
}
export class InvalidSessionStateError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidSessionStateError";
}
}

View File

@@ -5,6 +5,7 @@ import { createApiRoutes } from "./routes.js";
import type { TaskStore, TaskAttachment } from "@kb/core";
import type { TaskDetail } from "@kb/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { __resetPlanningState } from "./planning.js";
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
@@ -2550,4 +2551,355 @@ describe("Git Management endpoints", () => {
});
});
});
describe("Planning Mode Routes", () => {
beforeEach(() => {
// Reset planning state before each test to avoid cross-test contamination
__resetPlanningState();
});
describe("POST /planning/start", () => {
it("creates a new planning session", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(res.body.sessionId).toBeDefined();
expect(typeof res.body.sessionId).toBe("string");
expect(res.body.firstQuestion).toBeDefined();
expect(res.body.firstQuestion.id).toBe("q-scope");
expect(res.body.firstQuestion.type).toBe("single_select");
});
it("requires initialPlan in body", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("initialPlan is required");
});
it("rejects initialPlan longer than 500 chars", async () => {
const longPlan = "a".repeat(501);
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: longPlan }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("500 characters");
});
it("enforces rate limiting (5 sessions per hour per IP)", async () => {
// Create 5 sessions (should succeed)
for (let i = 0; i < 5; i++) {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: `Plan ${i}` }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
}
// 6th session should be rate limited
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Plan 6" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(429);
expect(res.body.error).toContain("Rate limit exceeded");
});
});
describe("POST /planning/respond", () => {
it("processes response and returns next question", async () => {
// First create a session
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
expect(startRes.status).toBe(201);
const sessionId = startRes.body.sessionId;
// Submit a response
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.type).toBe("question");
expect(res.body.data).toBeDefined();
});
it("returns summary after completing all questions", async () => {
// Create a session
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
const sessionId = startRes.body.sessionId;
// Submit 3 responses to complete the session
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
{ "Content-Type": "application/json" }
);
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
{ "Content-Type": "application/json" }
);
const finalRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { confirm: true } }),
{ "Content-Type": "application/json" }
);
expect(finalRes.status).toBe(200);
expect(finalRes.body.type).toBe("complete");
expect(finalRes.body.data.title).toBeDefined();
expect(finalRes.body.data.description).toBeDefined();
expect(finalRes.body.data.suggestedSize).toBeDefined();
expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array);
});
it("returns 404 for invalid session ID", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId: "invalid-session-id", responses: {} }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("requires sessionId in body", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ responses: {} }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("sessionId is required");
});
it("requires responses object", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId: "some-id" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("responses is required");
});
});
describe("POST /planning/cancel", () => {
it("cancels an active session", async () => {
// Create a session first
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
const sessionId = startRes.body.sessionId;
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/cancel",
JSON.stringify({ sessionId }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it("returns 404 for non-existent session", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/cancel",
JSON.stringify({ sessionId: "non-existent-id" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("requires sessionId in body", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/cancel",
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("sessionId is required");
});
});
describe("POST /planning/create-task", () => {
it("creates a task from completed planning session", async () => {
// Setup mock store for task creation
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-042",
description: "Build a user auth system",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
// Create a session and complete it
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
const sessionId = startRes.body.sessionId;
// Complete the session
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
{ "Content-Type": "application/json" }
);
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
{ "Content-Type": "application/json" }
);
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { confirm: true } }),
{ "Content-Type": "application/json" }
);
// Create task from planning
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalled();
});
it("returns 400 if session is not complete", async () => {
// Create a session but don't complete it
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
const sessionId = startRes.body.sessionId;
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("not complete");
});
it("returns 404 for invalid session ID", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId: "invalid-session-id" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("requires sessionId in body", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-task",
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("sessionId is required");
});
});
});
});

View File

@@ -1893,6 +1893,156 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── Planning Mode Routes ──────────────────────────────────────────────────
/**
* POST /api/planning/start
* Start a new planning session.
* Body: { initialPlan: string }
* Returns: { sessionId: string, firstQuestion: PlanningQuestion }
*/
router.post("/planning/start", async (req, res) => {
try {
const { initialPlan } = req.body;
if (!initialPlan || typeof initialPlan !== "string") {
res.status(400).json({ error: "initialPlan is required and must be a string" });
return;
}
if (initialPlan.length > 500) {
res.status(400).json({ error: "initialPlan must be 500 characters or less" });
return;
}
const ip = req.ip || req.socket.remoteAddress || "unknown";
const { createSession, RateLimitError } = await import("./planning.js");
const result = await createSession(ip, initialPlan);
res.status(201).json(result);
} catch (err: any) {
if (err.name === "RateLimitError") {
res.status(429).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to start planning session" });
}
}
});
/**
* POST /api/planning/respond
* Submit a response to the current planning question.
* Body: { sessionId: string, responses: Record<string, unknown> }
* Returns: { type: "question" | "complete", data: PlanningQuestion | PlanningSummary }
*/
router.post("/planning/respond", async (req, res) => {
try {
const { sessionId, responses } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
if (!responses || typeof responses !== "object") {
res.status(400).json({ error: "responses is required and must be an object" });
return;
}
const { submitResponse, SessionNotFoundError, InvalidSessionStateError } = await import("./planning.js");
const result = await submitResponse(sessionId, responses);
res.json(result);
} catch (err: any) {
if (err.name === "SessionNotFoundError") {
res.status(404).json({ error: err.message });
} else if (err.name === "InvalidSessionStateError") {
res.status(400).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to process response" });
}
}
});
/**
* POST /api/planning/cancel
* Cancel and cleanup a planning session.
* Body: { sessionId: string }
*/
router.post("/planning/cancel", async (req, res) => {
try {
const { sessionId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
const { cancelSession, SessionNotFoundError } = await import("./planning.js");
await cancelSession(sessionId);
res.json({ success: true });
} catch (err: any) {
if (err.name === "SessionNotFoundError") {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to cancel session" });
}
}
});
/**
* POST /api/planning/create-task
* Create a task from a completed planning session.
* Body: { sessionId: string }
* Returns: Created Task
*/
router.post("/planning/create-task", async (req, res) => {
try {
const { sessionId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
const { getSession, getSummary, cleanupSession, SessionNotFoundError } = await import("./planning.js");
const session = getSession(sessionId);
if (!session) {
res.status(404).json({ error: `Planning session ${sessionId} not found or expired` });
return;
}
const summary = getSummary(sessionId);
if (!summary) {
res.status(400).json({ error: "Planning session is not complete" });
return;
}
// Create the task
const task = await store.createTask({
title: summary.title,
description: summary.description,
column: "triage",
dependencies: summary.suggestedDependencies.length > 0 ? summary.suggestedDependencies : undefined,
});
// Update task with suggested size if provided
if (summary.suggestedSize) {
await store.updateTask(task.id, { size: summary.suggestedSize });
}
// Log the planning mode creation
await store.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${session.initialPlan.slice(0, 200)}`);
// Cleanup the session
cleanupSession(sessionId);
res.status(201).json(task);
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to create task" });
}
});
return router;
}