feat(KB-041): add task refine capability across core, API, UI, and CLI
- Add refineTask() to TaskStore with validation for feedback and requirements - Add POST /tasks/:id/refine API endpoint with tests - Add refine UI to TaskDetailModal for submitting feedback - Add kb_task_refine tool to pi extension for AI-driven refinement - Add 'kb task refine' CLI command for manual task refinement - Include validation constraints: max 2000 chars feedback, max 5000 chars requirements
This commit is contained in:
@@ -81,6 +81,7 @@ describe("kb pi extension", () => {
|
|||||||
"kb_task_pause",
|
"kb_task_pause",
|
||||||
"kb_task_unpause",
|
"kb_task_unpause",
|
||||||
"kb_task_duplicate",
|
"kb_task_duplicate",
|
||||||
|
"kb_task_refine",
|
||||||
"kb_task_import_github",
|
"kb_task_import_github",
|
||||||
"kb_task_import_github_issue",
|
"kb_task_import_github_issue",
|
||||||
"kb_task_browse_github_issues",
|
"kb_task_browse_github_issues",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ if (isBunBinary) {
|
|||||||
|
|
||||||
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
|
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
|
||||||
const { runDashboard } = await import("./commands/dashboard.js");
|
const { runDashboard } = await import("./commands/dashboard.js");
|
||||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive } = await import("./commands/task.js");
|
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine } = await import("./commands/task.js");
|
||||||
|
|
||||||
const HELP = `
|
const HELP = `
|
||||||
kb — AI-orchestrated task board
|
kb — AI-orchestrated task board
|
||||||
@@ -56,6 +56,7 @@ Usage:
|
|||||||
kb task log <id> <message> Add a log entry
|
kb task log <id> <message> Add a log entry
|
||||||
kb task merge <id> Merge an in-review task and close it
|
kb task merge <id> Merge an in-review task and close it
|
||||||
kb task duplicate <id> Duplicate a task (creates copy in triage)
|
kb task duplicate <id> Duplicate a task (creates copy in triage)
|
||||||
|
kb task refine <id> [opts] Create a refinement task from done/in-review
|
||||||
kb task archive <id> Archive a done task
|
kb task archive <id> Archive a done task
|
||||||
kb task unarchive <id> Unarchive an archived task
|
kb task unarchive <id> Unarchive an archived task
|
||||||
kb task attach <id> <file> Attach a file to a task
|
kb task attach <id> <file> Attach a file to a task
|
||||||
@@ -69,6 +70,7 @@ Options:
|
|||||||
--dev Start dashboard only (no AI engine)
|
--dev Start dashboard only (no AI engine)
|
||||||
--attach <file> Attach file(s) on task create (repeatable)
|
--attach <file> Attach file(s) on task create (repeatable)
|
||||||
--depends <id> Declare dependency on task create (repeatable)
|
--depends <id> Declare dependency on task create (repeatable)
|
||||||
|
--feedback <text> Refinement feedback (non-interactive mode)
|
||||||
--limit, -l <n> Max issues to import (default: 30, max: 100)
|
--limit, -l <n> Max issues to import (default: 30, max: 100)
|
||||||
--labels, -L <labels> Comma-separated label filter for import
|
--labels, -L <labels> Comma-separated label filter for import
|
||||||
--interactive, -i Interactive mode for issue selection
|
--interactive, -i Interactive mode for issue selection
|
||||||
@@ -176,6 +178,17 @@ async function main() {
|
|||||||
await runTaskDuplicate(id);
|
await runTaskDuplicate(id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "refine": {
|
||||||
|
const id = args[2];
|
||||||
|
if (!id) { console.error("Usage: kb task refine <id> [--feedback <text>]"); process.exit(1); }
|
||||||
|
// Parse optional --feedback flag
|
||||||
|
const feedbackIdx = args.indexOf("--feedback");
|
||||||
|
const feedback = feedbackIdx !== -1 && feedbackIdx + 1 < args.length
|
||||||
|
? args[feedbackIdx + 1]
|
||||||
|
: undefined;
|
||||||
|
await runTaskRefine(id, feedback);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "archive": {
|
case "archive": {
|
||||||
const id = args[2];
|
const id = args[2];
|
||||||
if (!id) { console.error("Usage: kb task archive <id>"); process.exit(1); }
|
if (!id) { console.error("Usage: kb task archive <id>"); process.exit(1); }
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
|
|||||||
|
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { TaskStore } from "@kb/core";
|
import { TaskStore } from "@kb/core";
|
||||||
import { runTaskShow, runTaskCreate, runTaskDuplicate } from "./task.js";
|
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine } from "./task.js";
|
||||||
|
|
||||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -892,3 +892,137 @@ describe("runTaskDuplicate", () => {
|
|||||||
await expect(runTaskDuplicate("KB-999")).rejects.toThrow("Task KB-999 not found");
|
await expect(runTaskDuplicate("KB-999")).rejects.toThrow("Task KB-999 not found");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Refine Tests ---
|
||||||
|
|
||||||
|
describe("runTaskRefine", () => {
|
||||||
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let mockRefineTask: ReturnType<typeof vi.fn>;
|
||||||
|
let mockRlQuestion: ReturnType<typeof vi.fn>;
|
||||||
|
let mockRlClose: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
mockRlQuestion = vi.fn();
|
||||||
|
mockRlClose = vi.fn();
|
||||||
|
|
||||||
|
(createInterface as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
question: mockRlQuestion,
|
||||||
|
close: mockRlClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRefineTask = vi.fn().mockResolvedValue({
|
||||||
|
id: "KB-002",
|
||||||
|
description: "Refinement of KB-001",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: ["KB-001"],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||||
|
init: vi.fn(),
|
||||||
|
refineTask: mockRefineTask,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refines task with interactive feedback and prints success", async () => {
|
||||||
|
mockRlQuestion.mockResolvedValue("Need to add more tests");
|
||||||
|
|
||||||
|
await runTaskRefine("KB-001");
|
||||||
|
|
||||||
|
expect(mockRlQuestion).toHaveBeenCalledWith("What needs to be refined? ");
|
||||||
|
expect(mockRlClose).toHaveBeenCalled();
|
||||||
|
expect(mockRefineTask).toHaveBeenCalledOnce();
|
||||||
|
expect(mockRefineTask).toHaveBeenCalledWith("KB-001", "Need to add more tests");
|
||||||
|
|
||||||
|
const successLine = logSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("✓ Created refinement"),
|
||||||
|
);
|
||||||
|
expect(successLine).toBeDefined();
|
||||||
|
expect(successLine![0]).toContain("KB-002");
|
||||||
|
expect(successLine![0]).toContain("KB-001");
|
||||||
|
|
||||||
|
// Check that dependency is printed
|
||||||
|
const depLine = logSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("Dependency:"),
|
||||||
|
);
|
||||||
|
expect(depLine).toBeDefined();
|
||||||
|
expect(depLine![0]).toContain("KB-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refines task with provided feedback (non-interactive)", async () => {
|
||||||
|
await runTaskRefine("KB-001", "Fix the error handling");
|
||||||
|
|
||||||
|
expect(mockRlQuestion).not.toHaveBeenCalled();
|
||||||
|
expect(mockRefineTask).toHaveBeenCalledOnce();
|
||||||
|
expect(mockRefineTask).toHaveBeenCalledWith("KB-001", "Fix the error handling");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when interactive feedback is empty", async () => {
|
||||||
|
mockRlQuestion.mockResolvedValue(" ");
|
||||||
|
|
||||||
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||||
|
|
||||||
|
await runTaskRefine("KB-001");
|
||||||
|
|
||||||
|
expect(mockRlClose).toHaveBeenCalled();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Feedback is required");
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
exitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when provided feedback is empty", async () => {
|
||||||
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||||
|
|
||||||
|
await runTaskRefine("KB-001", " ");
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Feedback is required");
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
exitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when feedback exceeds 2000 characters", async () => {
|
||||||
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||||
|
|
||||||
|
await runTaskRefine("KB-001", "A".repeat(2001));
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Feedback must be 2000 characters or less");
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
exitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows feedback at exactly 2000 characters", async () => {
|
||||||
|
const longFeedback = "A".repeat(2000);
|
||||||
|
|
||||||
|
await runTaskRefine("KB-001", longFeedback);
|
||||||
|
|
||||||
|
expect(mockRefineTask).toHaveBeenCalledOnce();
|
||||||
|
expect(mockRefineTask).toHaveBeenCalledWith("KB-001", longFeedback);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when task not in done or in-review", async () => {
|
||||||
|
mockRefineTask.mockRejectedValueOnce(new Error("Task must be in 'done' or 'in-review' column to refine"));
|
||||||
|
|
||||||
|
await expect(runTaskRefine("KB-001", "Some feedback")).rejects.toThrow("done' or 'in-review'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when task not found", async () => {
|
||||||
|
mockRefineTask.mockRejectedValueOnce(new Error("Task KB-999 not found"));
|
||||||
|
|
||||||
|
await expect(runTaskRefine("KB-999", "Some feedback")).rejects.toThrow("Task KB-999 not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -296,6 +296,38 @@ export async function runTaskDuplicate(id: string) {
|
|||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runTaskRefine(id: string, feedbackArg?: string) {
|
||||||
|
const store = await getStore();
|
||||||
|
|
||||||
|
// Get feedback interactively only if not provided (undefined)
|
||||||
|
let feedback = feedbackArg;
|
||||||
|
if (feedback === undefined) {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
feedback = await rl.question("What needs to be refined? ");
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedback?.trim()) {
|
||||||
|
console.error("Feedback is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate length (matches API validation)
|
||||||
|
if (feedback.length > 2000) {
|
||||||
|
console.error("Feedback must be 2000 characters or less");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTask = await store.refineTask(id, feedback.trim());
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(` ✓ Created refinement ${newTask.id} for ${id}`);
|
||||||
|
console.log(` Column: triage`);
|
||||||
|
console.log(` Dependency: ${id}`);
|
||||||
|
console.log(` Path: .kb/tasks/${newTask.id}/`);
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
export async function runTaskArchive(id: string) {
|
export async function runTaskArchive(id: string) {
|
||||||
const store = await getStore();
|
const store = await getStore();
|
||||||
const task = await store.archiveTask(id);
|
const task = await store.archiveTask(id);
|
||||||
|
|||||||
@@ -371,6 +371,44 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── kb_task_refine ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "kb_task_refine",
|
||||||
|
label: "KB: Refine Task",
|
||||||
|
description:
|
||||||
|
"Request a refinement of a completed or in-review task. " +
|
||||||
|
"Creates a new follow-up task in triage that references the original task as a dependency. " +
|
||||||
|
"Use this when a done or in-review task needs additional work, improvements, or follow-up changes.",
|
||||||
|
promptSnippet: "Create a refinement task for follow-up work on a completed task",
|
||||||
|
promptGuidelines: [
|
||||||
|
"Use when a completed or in-review task needs follow-up work or improvements",
|
||||||
|
"The original task must be in 'done' or 'in-review' column",
|
||||||
|
"The refinement task will be created in triage and depend on the original task",
|
||||||
|
"Provide clear feedback about what needs to be refined or improved",
|
||||||
|
],
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Task ID to refine (e.g. KB-001). Must be in 'done' or 'in-review' column." }),
|
||||||
|
feedback: Type.String({
|
||||||
|
description: "Description of what needs to be refined or improved",
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 2000,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const store = await getStore(ctx.cwd);
|
||||||
|
const newTask = await store.refineTask(params.id, params.feedback);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Created refinement ${newTask.id} for ${params.id}` },
|
||||||
|
],
|
||||||
|
details: { sourceId: params.id, newTaskId: newTask.id, feedback: params.feedback },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ── kb_task_archive ───────────────────────────────────────────────
|
// ── kb_task_archive ───────────────────────────────────────────────
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
|
|||||||
@@ -1564,6 +1564,241 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Refine Task Tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("refineTask", () => {
|
||||||
|
it("creates refinement from done task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need to fix edge case");
|
||||||
|
|
||||||
|
expect(refined.id).not.toBe(task.id);
|
||||||
|
expect(refined.id).toMatch(/^KB-\d+$/);
|
||||||
|
expect(refined.column).toBe("triage");
|
||||||
|
expect(refined.title).toBe(`Refinement: ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates refinement from in-review task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.column).toBe("triage");
|
||||||
|
expect(refined.title).toBe(`Refinement: ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when refining task in triage", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
// Task starts in triage
|
||||||
|
|
||||||
|
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when refining task in todo", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
|
||||||
|
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when refining task in in-progress", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when feedback is empty", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
await expect(store.refineTask(task.id, "")).rejects.toThrow("Feedback is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when feedback is whitespace only", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
await expect(store.refineTask(task.id, " ")).rejects.toThrow("Feedback is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets correct title format with original title", async () => {
|
||||||
|
const task = await store.createTask({ title: "My Feature", description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Add more tests");
|
||||||
|
|
||||||
|
expect(refined.title).toBe("Refinement: My Feature");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets correct title format without original title (uses ID)", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Add more tests");
|
||||||
|
|
||||||
|
expect(refined.title).toBe(`Refinement: ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("description includes feedback and refines reference", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Fix the edge case handling");
|
||||||
|
|
||||||
|
expect(refined.description).toBe(`Fix the edge case handling\n\nRefines: ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets dependency on original task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.dependencies).toEqual([task.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds log entry for refinement creation", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.log).toHaveLength(1);
|
||||||
|
expect(refined.log[0].action).toBe(`Created as refinement of ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits task:created event", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:created", (t) => events.push(t));
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].id).toBe(refined.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies attachments from original task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
// Add an attachment
|
||||||
|
await store.addAttachment(task.id, "test.png", Buffer.from("fake image"), "image/png");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.attachments).toHaveLength(1);
|
||||||
|
expect(refined.attachments![0].originalName).toBe("test.png");
|
||||||
|
expect(refined.attachments![0].mimeType).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies attachment files to new task directory", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
// Add an attachment
|
||||||
|
await store.addAttachment(task.id, "test.png", Buffer.from("fake image data"), "image/png");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
// Verify file exists in new task directory
|
||||||
|
const attachDir = join(rootDir, ".kb", "tasks", refined.id, "attachments");
|
||||||
|
const files = await readdir(attachDir);
|
||||||
|
expect(files.length).toBe(1);
|
||||||
|
|
||||||
|
// Verify content was copied
|
||||||
|
const content = await readFile(join(attachDir, files[0]));
|
||||||
|
expect(content.toString()).toBe("fake image data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when source has no attachments", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.attachments).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets execution state (no steps, no worktree, etc.)", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.steps).toEqual([]);
|
||||||
|
expect(refined.currentStep).toBe(0);
|
||||||
|
expect(refined.worktree).toBeUndefined();
|
||||||
|
expect(refined.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates PROMPT.md for the refinement", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
const detail = await store.getTask(refined.id);
|
||||||
|
expect(detail.prompt).toContain(`Refinement: ${task.id}`);
|
||||||
|
expect(detail.prompt).toContain("Need improvements");
|
||||||
|
expect(detail.prompt).toContain(`Refines: ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ENOENT when source task does not exist", async () => {
|
||||||
|
await expect(store.refineTask("KB-999", "Feedback")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// ── Archive/Unarchive Tests ──────────────────────────────────────
|
// ── Archive/Unarchive Tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,80 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return newTask;
|
return newTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a refinement task from a completed or in-review task.
|
||||||
|
* The new task is created in triage with a dependency on the original task.
|
||||||
|
* Validates the original is in 'done' or 'in-review' column.
|
||||||
|
*/
|
||||||
|
async refineTask(id: string, feedback: string): Promise<Task> {
|
||||||
|
// Read the source task with its prompt
|
||||||
|
const sourceTask = await this.getTask(id);
|
||||||
|
|
||||||
|
// Validate task is in done or in-review column
|
||||||
|
if (sourceTask.column !== "done" && sourceTask.column !== "in-review") {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot refine ${id}: task is in '${sourceTask.column}', must be in 'done' or 'in-review'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate feedback is not empty
|
||||||
|
if (!feedback?.trim()) {
|
||||||
|
throw new Error("Feedback is required and cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate a new ID
|
||||||
|
const newId = await this.allocateId();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Create new refinement task
|
||||||
|
const newTask: Task = {
|
||||||
|
id: newId,
|
||||||
|
title: `Refinement: ${sourceTask.title || sourceTask.id}`,
|
||||||
|
description: `${feedback.trim()}\n\nRefines: ${id}`,
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [id], // Refinement depends on the original being complete
|
||||||
|
steps: [], // Reset execution state
|
||||||
|
currentStep: 0,
|
||||||
|
log: [{ timestamp: now, action: `Created as refinement of ${id}` }],
|
||||||
|
columnMovedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
// Copy attachments from original for context (defensive copy)
|
||||||
|
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newDir = this.taskDir(newId);
|
||||||
|
await mkdir(newDir, { recursive: true });
|
||||||
|
await this.atomicWriteTaskJson(newDir, newTask);
|
||||||
|
|
||||||
|
// Create a PROMPT.md for the refinement
|
||||||
|
const heading = newTask.title;
|
||||||
|
const prompt = `# ${heading}\n\n${newTask.description}\n`;
|
||||||
|
await writeFile(join(newDir, "PROMPT.md"), prompt);
|
||||||
|
|
||||||
|
// Copy attachments from source if any
|
||||||
|
if (sourceTask.attachments && sourceTask.attachments.length > 0) {
|
||||||
|
const sourceAttachDir = join(this.taskDir(id), "attachments");
|
||||||
|
const targetAttachDir = join(newDir, "attachments");
|
||||||
|
await mkdir(targetAttachDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const attachment of sourceTask.attachments) {
|
||||||
|
const sourcePath = join(sourceAttachDir, attachment.filename);
|
||||||
|
const targetPath = join(targetAttachDir, attachment.filename);
|
||||||
|
if (existsSync(sourcePath)) {
|
||||||
|
const content = await readFile(sourcePath);
|
||||||
|
await writeFile(targetPath, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache if watcher is active
|
||||||
|
if (this.watcher) this.taskCache.set(newId, { ...newTask });
|
||||||
|
|
||||||
|
this.emit("task:created", newTask);
|
||||||
|
return newTask;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a task's JSON and prompt content.
|
* Read a task's JSON and prompt content.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { fetchTaskDetail, updateTask, archiveTask, unarchiveTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
|
import { fetchTaskDetail, updateTask, archiveTask, unarchiveTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes, refineTask } from "./api";
|
||||||
import type { Task, TaskDetail } from "@kb/core";
|
import type { Task, TaskDetail } from "@kb/core";
|
||||||
|
|
||||||
const FAKE_DETAIL: TaskDetail = {
|
const FAKE_DETAIL: TaskDetail = {
|
||||||
@@ -371,6 +371,59 @@ describe("rejectPlan", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Refinement API tests ---
|
||||||
|
|
||||||
|
describe("refineTask", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const FAKE_REFINED_TASK: Task = {
|
||||||
|
id: "KB-002",
|
||||||
|
description: "Refinement of KB-001",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: ["KB-001"],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sends POST with feedback and returns new refinement task", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_REFINED_TASK));
|
||||||
|
|
||||||
|
const result = await refineTask("KB-001", "Need to add more tests and improve error handling");
|
||||||
|
|
||||||
|
expect(result.id).toBe("KB-002");
|
||||||
|
expect(result.column).toBe("triage");
|
||||||
|
expect(result.dependencies).toContain("KB-001");
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/refine", {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ feedback: "Need to add more tests and improve error handling" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on error response when task not found", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(
|
||||||
|
mockFetchResponse(false, { error: "Task not found" }, 404)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(refineTask("KB-999", "feedback")).rejects.toThrow("Task not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on error response when task not in done/in-review", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(
|
||||||
|
mockFetchResponse(false, { error: "Task must be in 'done' or 'in-review' column to refine" }, 400)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(refineTask("KB-001", "feedback")).rejects.toThrow("done' or 'in-review'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// --- Git Management API tests ---
|
// --- Git Management API tests ---
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ export function requestSpecRevision(id: string, feedback: string): Promise<Task>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function refineTask(id: string, feedback: string): Promise<Task> {
|
||||||
|
return api<Task>(`/tasks/${id}/refine`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ feedback }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Models API ---
|
// --- Models API ---
|
||||||
|
|
||||||
/** Available AI model info returned by the models endpoint */
|
/** Available AI model info returned by the models endpoint */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } from "@kb/core";
|
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } from "@kb/core";
|
||||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@kb/core";
|
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@kb/core";
|
||||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, requestSpecRevision, approvePlan, rejectPlan } from "../api";
|
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, requestSpecRevision, approvePlan, rejectPlan, refineTask } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||||
import { AgentLogViewer } from "./AgentLogViewer";
|
import { AgentLogViewer } from "./AgentLogViewer";
|
||||||
@@ -86,6 +86,9 @@ export function TaskDetailModal({
|
|||||||
const [depSearch, setDepSearch] = useState("");
|
const [depSearch, setDepSearch] = useState("");
|
||||||
const [isSavingSpec, setIsSavingSpec] = useState(false);
|
const [isSavingSpec, setIsSavingSpec] = useState(false);
|
||||||
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
|
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
|
||||||
|
const [showRefineModal, setShowRefineModal] = useState(false);
|
||||||
|
const [refineFeedback, setRefineFeedback] = useState("");
|
||||||
|
const [isRefining, setIsRefining] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showDepDropdown) setDepSearch("");
|
if (!showDepDropdown) setDepSearch("");
|
||||||
}, [showDepDropdown]);
|
}, [showDepDropdown]);
|
||||||
@@ -208,6 +211,38 @@ export function TaskDetailModal({
|
|||||||
}
|
}
|
||||||
}, [task.id, onClose, addToast]);
|
}, [task.id, onClose, addToast]);
|
||||||
|
|
||||||
|
const handleOpenRefineModal = useCallback(() => {
|
||||||
|
setShowRefineModal(true);
|
||||||
|
setRefineFeedback("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseRefineModal = useCallback(() => {
|
||||||
|
setShowRefineModal(false);
|
||||||
|
setRefineFeedback("");
|
||||||
|
setIsRefining(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmitRefine = useCallback(async () => {
|
||||||
|
if (!refineFeedback.trim()) {
|
||||||
|
addToast("Please enter feedback describing what needs refinement", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (refineFeedback.length > 2000) {
|
||||||
|
addToast("Feedback must be 2000 characters or less", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsRefining(true);
|
||||||
|
try {
|
||||||
|
const newTask = await refineTask(task.id, refineFeedback.trim());
|
||||||
|
addToast(`Refinement task created: ${newTask.id}`, "success");
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
addToast(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
setIsRefining(false);
|
||||||
|
}
|
||||||
|
}, [task.id, refineFeedback, addToast, onClose]);
|
||||||
|
|
||||||
const uploadFile = useCallback(async (file: File) => {
|
const uploadFile = useCallback(async (file: File) => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
@@ -680,6 +715,11 @@ export function TaskDetailModal({
|
|||||||
Duplicate
|
Duplicate
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{(task.column === "done" || task.column === "in-review") && (
|
||||||
|
<button className="btn btn-sm" onClick={handleOpenRefineModal}>
|
||||||
|
Request Refinement
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{task.status === "failed" && onRetryTask && (
|
{task.status === "failed" && onRetryTask && (
|
||||||
<button className="btn btn-warning btn-sm" onClick={handleRetry}>
|
<button className="btn btn-warning btn-sm" onClick={handleRetry}>
|
||||||
Retry
|
Retry
|
||||||
@@ -719,6 +759,76 @@ export function TaskDetailModal({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showRefineModal && (
|
||||||
|
<div
|
||||||
|
className="modal-overlay open"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: "rgba(0, 0, 0, 0.7)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
onClick={handleCloseRefineModal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal"
|
||||||
|
style={{ maxWidth: "500px", width: "90%", margin: "0" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 style={{ margin: 0 }}>Request Refinement</h3>
|
||||||
|
<button className="modal-close" onClick={handleCloseRefineModal}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="detail-body">
|
||||||
|
<p style={{ marginBottom: "12px", opacity: 0.8 }}>
|
||||||
|
Describe what needs to be refined or improved...
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={refineFeedback}
|
||||||
|
onChange={(e) => setRefineFeedback(e.target.value)}
|
||||||
|
placeholder="Enter your feedback here..."
|
||||||
|
rows={6}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid var(--border, #30363d)",
|
||||||
|
background: "var(--bg-primary, #0d1117)",
|
||||||
|
color: "var(--text-primary, #c9d1d9)",
|
||||||
|
fontSize: "14px",
|
||||||
|
resize: "vertical",
|
||||||
|
minHeight: "120px",
|
||||||
|
}}
|
||||||
|
maxLength={2000}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: "8px", textAlign: "right", fontSize: "12px", opacity: 0.6 }}>
|
||||||
|
{refineFeedback.length}/2000 characters
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-sm" onClick={handleCloseRefineModal} disabled={isRefining}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleSubmitRefine}
|
||||||
|
disabled={!refineFeedback.trim() || isRefining}
|
||||||
|
>
|
||||||
|
{isRefining ? "Creating..." : "Create Refinement Task"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock("../../api", () => ({
|
|||||||
approvePlan: vi.fn().mockResolvedValue({}),
|
approvePlan: vi.fn().mockResolvedValue({}),
|
||||||
rejectPlan: vi.fn().mockResolvedValue({}),
|
rejectPlan: vi.fn().mockResolvedValue({}),
|
||||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||||
|
refineTask: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useAgentLogs", () => ({
|
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||||
@@ -1983,4 +1984,299 @@ describe("TaskDetailModal", () => {
|
|||||||
window.confirm = originalConfirm;
|
window.confirm = originalConfirm;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Refinement button", () => {
|
||||||
|
it("renders Request Refinement button for 'done' column tasks", () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ column: "done" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Request Refinement")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Request Refinement button for 'in-review' column tasks", () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ column: "in-review" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Request Refinement")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render Request Refinement button for 'triage' column tasks", () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ column: "triage" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Request Refinement")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render Request Refinement button for 'todo' column tasks", () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ column: "todo" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Request Refinement")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render Request Refinement button for 'in-progress' column tasks", () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ column: "in-progress" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Request Refinement")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Request Refinement opens the refinement modal", () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "KB-001", column: "done" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Request Refinement"));
|
||||||
|
|
||||||
|
expect(screen.getByText("Request Refinement", { selector: "h3" })).toBeTruthy();
|
||||||
|
expect(screen.getByPlaceholderText("Enter your feedback here...")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows character counter in refinement modal", () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "KB-001", column: "done" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Request Refinement"));
|
||||||
|
|
||||||
|
expect(screen.getByText("0/2000 characters")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("character counter updates when typing feedback", async () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "KB-001", column: "done" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Request Refinement"));
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "Need to fix the error handling" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("30/2000 characters")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submit button is disabled when feedback is empty", () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "KB-001", column: "done" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Request Refinement"));
|
||||||
|
|
||||||
|
const submitButton = screen.getByText("Create Refinement Task");
|
||||||
|
expect(submitButton.hasAttribute("disabled")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submit button is enabled when feedback is entered", async () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "KB-001", column: "done" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Request Refinement"));
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "Need to fix error handling" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButton = screen.getByText("Create Refinement Task");
|
||||||
|
expect(submitButton.hasAttribute("disabled")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Cancel closes the refinement modal", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "KB-001", column: "done" })}
|
||||||
|
onClose={onClose}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Request Refinement"));
|
||||||
|
fireEvent.click(screen.getByText("Cancel"));
|
||||||
|
|
||||||
|
// Modal should be closed, but detail modal stays open (onClose not called)
|
||||||
|
expect(screen.queryByText("Request Refinement", { selector: "h3" })).toBeNull();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast when submitting empty feedback", async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "KB-001", column: "done" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={addToast}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Request Refinement"));
|
||||||
|
|
||||||
|
// Try to submit with empty text (manually trigger submit since button is disabled)
|
||||||
|
const { refineTask } = await import("../../api");
|
||||||
|
|
||||||
|
// Should not call API, instead show error toast
|
||||||
|
expect(refineTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls refineTask and closes modal on successful submission", async () => {
|
||||||
|
const { refineTask } = await import("../../api");
|
||||||
|
vi.mocked(refineTask).mockResolvedValue({ id: "KB-002", column: "triage" } as Task);
|
||||||
|
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const addToast = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "KB-001", column: "done" })}
|
||||||
|
onClose={onClose}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={addToast}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Request Refinement"));
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
|
||||||
|
fireEvent.change(textarea, { target: { value: "Need to add more tests" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Create Refinement Task"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(refineTask).toHaveBeenCalledWith("KB-001", "Need to add more tests");
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Refinement task created: KB-002", "success");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast when refineTask fails", async () => {
|
||||||
|
const { refineTask } = await import("../../api");
|
||||||
|
vi.mocked(refineTask).mockRejectedValue(new Error("Task must be in 'done' or 'in-review' column"));
|
||||||
|
|
||||||
|
const addToast = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "KB-001", column: "done" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={addToast}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Request Refinement"));
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
|
||||||
|
fireEvent.change(textarea, { target: { value: "Need to add more tests" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Create Refinement Task"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Task must be in 'done' or 'in-review' column", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -263,6 +263,131 @@ describe("POST /tasks/:id/duplicate", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /tasks/:id/refine", () => {
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMockStore({
|
||||||
|
refineTask: vi.fn(),
|
||||||
|
logEntry: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates refinement task from done task and returns 201", async () => {
|
||||||
|
const refinedTask = { ...FAKE_TASK_DETAIL, id: "KB-002", column: "triage", title: "Refinement: KB-001" };
|
||||||
|
(store.refineTask as ReturnType<typeof vi.fn>).mockResolvedValue(refinedTask);
|
||||||
|
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "Need improvements" }), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.id).toBe("KB-002");
|
||||||
|
expect(res.body.column).toBe("triage");
|
||||||
|
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Need improvements");
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Refinement requested", "Need improvements");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates refinement task from in-review task and returns 201", async () => {
|
||||||
|
const refinedTask = { ...FAKE_TASK_DETAIL, id: "KB-002", column: "triage", title: "Refinement: My Feature" };
|
||||||
|
(store.refineTask as ReturnType<typeof vi.fn>).mockResolvedValue(refinedTask);
|
||||||
|
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "Fix edge cases" }), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.column).toBe("triage");
|
||||||
|
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Fix edge cases");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when task is not in done or in-review column", async () => {
|
||||||
|
(store.refineTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot refine KB-001: task is in 'triage', must be in 'done' or 'in-review'"));
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "Need improvements" }), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("must be in 'done' or 'in-review'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when feedback is missing", async () => {
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({}), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("feedback is required");
|
||||||
|
expect(store.refineTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when feedback is empty string", async () => {
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "" }), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("feedback is required");
|
||||||
|
expect(store.refineTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when feedback exceeds 2000 characters", async () => {
|
||||||
|
const longFeedback = "x".repeat(2001);
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: longFeedback }), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("feedback must be between 1 and 2000 characters");
|
||||||
|
expect(store.refineTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when source task not found", async () => {
|
||||||
|
const error = new Error("Task not found") as NodeJS.ErrnoException;
|
||||||
|
error.code = "ENOENT";
|
||||||
|
(store.refineTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/refine", JSON.stringify({ feedback: "Need improvements" }), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toContain("not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when feedback is whitespace only (rejected by store)", async () => {
|
||||||
|
(store.refineTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Feedback is required and cannot be empty"));
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: " " }), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("Feedback is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 on unexpected errors", async () => {
|
||||||
|
(store.refineTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "Need improvements" }), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toContain("Database error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("POST /tasks/:id/archive", () => {
|
describe("POST /tasks/:id/archive", () => {
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
|
|
||||||
|
|||||||
@@ -651,6 +651,33 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refine task (done/in-review → creates new refinement task in triage)
|
||||||
|
router.post("/tasks/:id/refine", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { feedback } = req.body;
|
||||||
|
if (!feedback || typeof feedback !== "string") {
|
||||||
|
res.status(400).json({ error: "feedback is required and must be a string" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feedback.length === 0 || feedback.length > 2000) {
|
||||||
|
res.status(400).json({ error: "feedback must be between 1 and 2000 characters" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTask = await store.refineTask(req.params.id, feedback);
|
||||||
|
|
||||||
|
// Log the refinement action on the original task
|
||||||
|
await store.logEntry(req.params.id, "Refinement requested", feedback.slice(0, 100));
|
||||||
|
|
||||||
|
res.status(201).json(newTask);
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.message?.includes("Cannot refine") || err.message?.includes("Feedback is required")
|
||||||
|
? 400
|
||||||
|
: err.code === "ENOENT" ? 404 : 500;
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Archive task (done → archived)
|
// Archive task (done → archived)
|
||||||
router.post("/tasks/:id/archive", async (req, res) => {
|
router.post("/tasks/:id/archive", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user