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:
gsxdsm
2026-03-29 21:05:57 -07:00
parent 01255a35a5
commit c2f4f61d47
13 changed files with 1149 additions and 4 deletions

View File

@@ -81,6 +81,7 @@ describe("kb pi extension", () => {
"kb_task_pause",
"kb_task_unpause",
"kb_task_duplicate",
"kb_task_refine",
"kb_task_import_github",
"kb_task_import_github_issue",
"kb_task_browse_github_issues",

View File

@@ -39,7 +39,7 @@ if (isBunBinary) {
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
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 = `
kb — AI-orchestrated task board
@@ -56,6 +56,7 @@ Usage:
kb task log <id> <message> Add a log entry
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 refine <id> [opts] Create a refinement task from done/in-review
kb task archive <id> Archive a done task
kb task unarchive <id> Unarchive an archived task
kb task attach <id> <file> Attach a file to a task
@@ -69,6 +70,7 @@ Options:
--dev Start dashboard only (no AI engine)
--attach <file> Attach file(s) 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)
--labels, -L <labels> Comma-separated label filter for import
--interactive, -i Interactive mode for issue selection
@@ -176,6 +178,17 @@ async function main() {
await runTaskDuplicate(id);
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": {
const id = args[2];
if (!id) { console.error("Usage: kb task archive <id>"); process.exit(1); }

View File

@@ -28,7 +28,7 @@ vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
import { createInterface } from "node:readline/promises";
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> = {}) {
return {
@@ -892,3 +892,137 @@ describe("runTaskDuplicate", () => {
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");
});
});

View File

@@ -296,6 +296,38 @@ export async function runTaskDuplicate(id: string) {
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) {
const store = await getStore();
const task = await store.archiveTask(id);

View File

@@ -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 ───────────────────────────────────────────────
pi.registerTool({

View File

@@ -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 ──────────────────────────────────────

View File

@@ -263,6 +263,80 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
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.
*

View File

@@ -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 {

View File

@@ -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 */

View File

@@ -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}>
&times;
</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>
);

View File

@@ -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");
});
});
});
});

View File

@@ -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;

View File

@@ -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 {