feat(KB-040): add break-into-subtasks toggle for task creation
- Add breakIntoSubtasks flag to core Task model and persist to store - Plumb subtask flag through dashboard API routes with validation - Add subtask toggle UI to inline task creation card - Update triage agent to handle automatic subtask breakdown when enabled - Add comprehensive tests for store, API routes, and UI components - Include changeset for patch release documenting the new feature
This commit is contained in:
@@ -105,6 +105,32 @@ describe("TaskStore", () => {
|
||||
expect(detail.prompt).toMatch(/^# KB-001: Add caching\n/);
|
||||
expect(detail.prompt).toContain("Implement caching layer for API responses");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("breakIntoSubtasks task creation flag", () => {
|
||||
it("persists breakIntoSubtasks=true when explicitly requested", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Large feature",
|
||||
breakIntoSubtasks: true,
|
||||
});
|
||||
|
||||
expect(task.breakIntoSubtasks).toBe(true);
|
||||
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.breakIntoSubtasks).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves breakIntoSubtasks unset by default", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Regular task",
|
||||
});
|
||||
|
||||
expect(task.breakIntoSubtasks).toBeUndefined();
|
||||
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.breakIntoSubtasks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Lock serialization test ──────────────────────────────────────
|
||||
|
||||
@@ -193,6 +193,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
description: input.description,
|
||||
column: input.column || "triage",
|
||||
dependencies: input.dependencies || [],
|
||||
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [{ timestamp: now, action: "Task created" }],
|
||||
|
||||
@@ -104,6 +104,8 @@ export interface Task {
|
||||
description: string;
|
||||
column: Column;
|
||||
dependencies: string[];
|
||||
/** User-requested hint for triage: prefer splitting into child tasks when appropriate. */
|
||||
breakIntoSubtasks?: boolean;
|
||||
worktree?: string;
|
||||
steps: TaskStep[];
|
||||
currentStep: number;
|
||||
@@ -166,6 +168,7 @@ export interface TaskCreateInput {
|
||||
description: string;
|
||||
column?: Column;
|
||||
dependencies?: string[];
|
||||
breakIntoSubtasks?: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
|
||||
@@ -32,9 +32,16 @@ export async function fetchTaskDetail(id: string): Promise<TaskDetail> {
|
||||
}
|
||||
|
||||
export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
const { title, description, column, dependencies, breakIntoSubtasks } = input;
|
||||
return api<Task>("/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [showDeps, setShowDeps] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [breakIntoSubtasks, setBreakIntoSubtasks] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -44,13 +45,19 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
// relatedTarget is the element receiving focus — if it's inside the card, ignore
|
||||
if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return;
|
||||
// Only cancel if empty and dropdown is not open
|
||||
if (description.trim() === "" && pendingImages.length === 0 && dependencies.length === 0 && !showDeps) {
|
||||
if (
|
||||
description.trim() === "" &&
|
||||
pendingImages.length === 0 &&
|
||||
dependencies.length === 0 &&
|
||||
!breakIntoSubtasks &&
|
||||
!showDeps
|
||||
) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
card.addEventListener("focusout", handleFocusOut);
|
||||
return () => card.removeEventListener("focusout", handleFocusOut);
|
||||
}, [description, pendingImages, dependencies, showDeps, onCancel]);
|
||||
}, [description, pendingImages, dependencies, breakIntoSubtasks, showDeps, onCancel]);
|
||||
|
||||
// Clean up object URLs on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
@@ -101,6 +108,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
description: description.trim(),
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
breakIntoSubtasks,
|
||||
});
|
||||
|
||||
// Upload pending images as attachments
|
||||
@@ -128,7 +136,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, submitting, pendingImages, onSubmit, addToast]);
|
||||
}, [description, dependencies, breakIntoSubtasks, submitting, pendingImages, onSubmit, addToast]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent) => {
|
||||
@@ -199,6 +207,17 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
>
|
||||
<Link size={12} style={{ verticalAlign: 'middle' }} />{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||
</button>
|
||||
{!submitting && (
|
||||
<label className="inline-create-hint" style={{ display: "inline-flex", alignItems: "center", gap: 6, marginLeft: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="break-into-subtasks-toggle"
|
||||
checked={breakIntoSubtasks}
|
||||
onChange={(e) => setBreakIntoSubtasks(e.target.checked)}
|
||||
/>
|
||||
Break into subtasks
|
||||
</label>
|
||||
)}
|
||||
{showDeps && (() => {
|
||||
const term = depSearch.toLowerCase();
|
||||
const filtered = (term
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { InlineCreateCard } from "../InlineCreateCard";
|
||||
import type { Task, Column } from "@kb/core";
|
||||
|
||||
@@ -187,3 +187,54 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-002");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard breakIntoSubtasks toggle", () => {
|
||||
it("renders toggle defaulted to off", () => {
|
||||
renderCard();
|
||||
const checkbox = screen.getByTestId("break-into-subtasks-toggle") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("can be toggled on", () => {
|
||||
renderCard();
|
||||
const checkbox = screen.getByTestId("break-into-subtasks-toggle") as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("passes breakIntoSubtasks in submit payload", async () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const checkbox = screen.getByTestId("break-into-subtasks-toggle") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Split this work" } });
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Split this work",
|
||||
breakIntoSubtasks: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes breakIntoSubtasks=false by default", async () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Simple task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Simple task",
|
||||
breakIntoSubtasks: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1357,6 +1357,7 @@ describe("ListView Inline Create Card", () => {
|
||||
expect(mockOnCreateTask).toHaveBeenCalledWith({
|
||||
description: "New task description",
|
||||
column: "triage",
|
||||
breakIntoSubtasks: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,78 @@ describe("GET /tasks/:id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("creates a task and forwards breakIntoSubtasks", async () => {
|
||||
const createdTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "triage",
|
||||
breakIntoSubtasks: true,
|
||||
};
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
description: "Big initiative",
|
||||
breakIntoSubtasks: true,
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith({
|
||||
title: undefined,
|
||||
description: "Big initiative",
|
||||
column: undefined,
|
||||
dependencies: undefined,
|
||||
breakIntoSubtasks: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when description is missing", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ breakIntoSubtasks: true }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("description is required");
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when breakIntoSubtasks is not a boolean", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Big initiative", breakIntoSubtasks: "yes" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("breakIntoSubtasks must be a boolean");
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/retry", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
||||
@@ -610,16 +610,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Create task
|
||||
router.post("/tasks", async (req, res) => {
|
||||
try {
|
||||
const { title, description, column, dependencies } = req.body;
|
||||
const { title, description, column, dependencies, breakIntoSubtasks } = req.body;
|
||||
if (!description || typeof description !== "string") {
|
||||
res.status(400).json({ error: "description is required" });
|
||||
return;
|
||||
}
|
||||
if (breakIntoSubtasks !== undefined && typeof breakIntoSubtasks !== "boolean") {
|
||||
res.status(400).json({ error: "breakIntoSubtasks must be a boolean" });
|
||||
return;
|
||||
}
|
||||
const task = await store.createTask({
|
||||
title,
|
||||
description,
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
});
|
||||
res.status(201).json(task);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -150,9 +150,18 @@ When you plan to list a task in the \`## Dependencies\` section, first call \`ta
|
||||
Use what you learn — file scope, APIs, patterns, completion criteria — to make the new spec accurate: reference the right paths, avoid conflicting assumptions, and describe what the dependency must deliver before this task starts.
|
||||
If the dependency task has no PROMPT.md yet (not yet specified), note that in the Dependencies section.
|
||||
|
||||
## Executor tools
|
||||
The executor agent has these extra built-in tools:
|
||||
- \`task_create\` — create follow-up tasks
|
||||
## Triage subtask breakdown
|
||||
When the task includes \`breakIntoSubtasks: true\`, first decide whether it should be split.
|
||||
|
||||
- Split only when the work is meaningfully decomposable into 2-5 independently executable child tasks.
|
||||
- If splitting: use the \`task_create\` tool to create child tasks in triage, include clear descriptions and dependencies between them, then stop. Do NOT write a PROMPT.md for the parent task.
|
||||
- If not splitting: proceed with a normal PROMPT.md specification.
|
||||
|
||||
## Triage tools
|
||||
You have these extra tools during triage:
|
||||
- \`task_list\` — list existing active tasks
|
||||
- \`task_get\` — inspect a task and its PROMPT.md
|
||||
- \`task_create\` — create a child/follow-up task while triaging
|
||||
|
||||
## Guidelines
|
||||
- Read the project structure and relevant source files to understand context BEFORE writing
|
||||
@@ -419,9 +428,15 @@ export class TriageProcessor {
|
||||
const specReviewVerdictRef: { current: ReviewVerdict | null } = {
|
||||
current: null,
|
||||
};
|
||||
// Track subtasks created during triage when breakIntoSubtasks was requested.
|
||||
const createdSubtasksRef: { current: string[] } = { current: [] };
|
||||
|
||||
const customTools = [
|
||||
...this.createTriageTools(),
|
||||
...this.createTriageTools({
|
||||
parentTaskId: task.id,
|
||||
allowTaskCreate: detail.breakIntoSubtasks === true,
|
||||
createdSubtasksRef,
|
||||
}),
|
||||
this.createReviewSpecTool(
|
||||
task.id,
|
||||
promptPath,
|
||||
@@ -497,6 +512,17 @@ export class TriageProcessor {
|
||||
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
|
||||
checkSessionError(session);
|
||||
|
||||
if (detail.breakIntoSubtasks && createdSubtasksRef.current.length > 0) {
|
||||
const childTaskIds = createdSubtasksRef.current.join(", ");
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Converted into subtasks: ${childTaskIds}`,
|
||||
);
|
||||
await this.store.deleteTask(task.id);
|
||||
triageLog.log(`✓ ${task.id} split into subtasks (${childTaskIds}) and closed`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Post-session APPROVE gate: only advance to todo when the spec
|
||||
// reviewer explicitly approved. Any other verdict (REVISE,
|
||||
// RETHINK, UNAVAILABLE) or a missing review (null) keeps the task
|
||||
@@ -636,12 +662,23 @@ export class TriageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private createTriageTools(): ToolDefinition[] {
|
||||
private createTriageTools(options: {
|
||||
parentTaskId: string;
|
||||
allowTaskCreate: boolean;
|
||||
createdSubtasksRef: { current: string[] };
|
||||
}): ToolDefinition[] {
|
||||
const store = this.store;
|
||||
|
||||
const taskGetParams = Type.Object({
|
||||
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
|
||||
});
|
||||
const taskCreateParams = Type.Object({
|
||||
title: Type.Optional(Type.String({ description: "Short child task title" })),
|
||||
description: Type.String({ description: "Child task description/mission" }),
|
||||
dependencies: Type.Optional(
|
||||
Type.Array(Type.String({ description: "Task ID dependency (e.g. KB-001)" })),
|
||||
),
|
||||
});
|
||||
|
||||
const taskList: ToolDefinition = {
|
||||
name: "task_list",
|
||||
@@ -712,7 +749,69 @@ export class TriageProcessor {
|
||||
},
|
||||
};
|
||||
|
||||
return [taskList, taskGet];
|
||||
const taskCreate: ToolDefinition = {
|
||||
name: "task_create",
|
||||
label: "Create Child Task",
|
||||
description:
|
||||
"Create a child task (subtask) while breaking a larger task into smaller pieces. " +
|
||||
"Use this when breakIntoSubtasks is enabled and the work can be split into 2-5 independently executable tasks. " +
|
||||
"The created task will be a child of the current task being triaged.",
|
||||
parameters: taskCreateParams,
|
||||
execute: async (
|
||||
_callId: string,
|
||||
params: Static<typeof taskCreateParams>,
|
||||
) => {
|
||||
if (!options.allowTaskCreate) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "ERROR: Task creation is not enabled for this task. The user did not request subtask breakdown.",
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const newTask = await store.createTask({
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
dependencies: params.dependencies || [],
|
||||
column: "triage",
|
||||
});
|
||||
|
||||
// Track the created subtask
|
||||
options.createdSubtasksRef.current.push(newTask.id);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Created child task ${newTask.id}: ${params.title || params.description.slice(0, 60)}`,
|
||||
},
|
||||
],
|
||||
details: { taskId: newTask.id },
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `ERROR: Failed to create task: ${err.message}`,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const tools: ToolDefinition[] = [taskList, taskGet];
|
||||
if (options.allowTaskCreate) {
|
||||
tools.push(taskCreate);
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1025,13 +1124,36 @@ ${feedback}
|
||||
Please revise the specification above to address this feedback. Write the complete revised PROMPT.md to \`${promptPath}\`.`;
|
||||
}
|
||||
|
||||
let subtaskSection = "";
|
||||
if (task.breakIntoSubtasks) {
|
||||
subtaskSection = `
|
||||
|
||||
## Subtask Breakdown Requested
|
||||
The user has requested that this task be broken into smaller subtasks if it is complex enough to warrant splitting.
|
||||
|
||||
**When to split:**
|
||||
- Only split when the work is meaningfully decomposable into 2-5 independently executable child tasks
|
||||
- Each child task should be completable on its own with a clear scope and acceptance criteria
|
||||
- Child tasks should have logical dependencies between them if order matters
|
||||
|
||||
**How to split:**
|
||||
1. First, analyze the task to determine if it should be split
|
||||
2. If splitting: use the \\\`task_create\\\` tool to create child tasks in order, setting up dependencies as needed
|
||||
3. Include clear descriptions and acceptance criteria for each child task
|
||||
4. After creating all subtasks, stop — do NOT write a PROMPT.md for the parent task
|
||||
5. If NOT splitting: proceed with a normal PROMPT.md specification for this task
|
||||
|
||||
**Important:** If you create subtasks, this parent task will be closed and replaced by the children. Make sure each child is a complete, executable task.`;
|
||||
}
|
||||
|
||||
return `${isRevision ? "Revise" : "Specify"} this task and write the result to \`${promptPath}\`.
|
||||
|
||||
## Task
|
||||
- **ID:** ${task.id}
|
||||
- **Title:** ${task.title || "(none)"}
|
||||
- **Description:** ${task.description}
|
||||
${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}${revisionSection}
|
||||
${task.breakIntoSubtasks ? "- **Break into subtasks:** Yes (user requested)" : ""}
|
||||
${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}${revisionSection}${subtaskSection}
|
||||
|
||||
## Instructions
|
||||
${isRevision ? "1. Review the existing specification and user feedback carefully\n2. Revise the PROMPT.md to address the feedback while maintaining the structure\n3. Ensure the specification is detailed enough for an AI agent to execute" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n4. Name actual files, functions, and patterns from the codebase — be specific"}
|
||||
|
||||
Reference in New Issue
Block a user