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:
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
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 ---
|
||||
|
||||
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 ---
|
||||
|
||||
/** Available AI model info returned by the models endpoint */
|
||||
|
||||
@@ -3,7 +3,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } 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 { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
@@ -86,6 +86,9 @@ export function TaskDetailModal({
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [isSavingSpec, setIsSavingSpec] = useState(false);
|
||||
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
|
||||
const [showRefineModal, setShowRefineModal] = useState(false);
|
||||
const [refineFeedback, setRefineFeedback] = useState("");
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!showDepDropdown) setDepSearch("");
|
||||
}, [showDepDropdown]);
|
||||
@@ -208,6 +211,38 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [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) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
@@ -680,6 +715,11 @@ export function TaskDetailModal({
|
||||
Duplicate
|
||||
</button>
|
||||
)}
|
||||
{(task.column === "done" || task.column === "in-review") && (
|
||||
<button className="btn btn-sm" onClick={handleOpenRefineModal}>
|
||||
Request Refinement
|
||||
</button>
|
||||
)}
|
||||
{task.status === "failed" && onRetryTask && (
|
||||
<button className="btn btn-warning btn-sm" onClick={handleRetry}>
|
||||
Retry
|
||||
@@ -719,6 +759,76 @@ export function TaskDetailModal({
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("../../api", () => ({
|
||||
approvePlan: vi.fn().mockResolvedValue({}),
|
||||
rejectPlan: vi.fn().mockResolvedValue({}),
|
||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||
refineTask: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||
@@ -1983,4 +1984,299 @@ describe("TaskDetailModal", () => {
|
||||
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", () => {
|
||||
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)
|
||||
router.post("/tasks/:id/archive", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user