feat(KB-204): add kb task steer command
- Add kb task steer CLI command with routing and help text - Implement runTaskSteer command handler in commands/task.ts - Add comprehensive unit tests for task steer functionality - Update bin.ts to wire up task steer subcommand
This commit is contained in:
253
packages/cli/src/__tests__/task-steer.test.ts
Normal file
253
packages/cli/src/__tests__/task-steer.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:readline/promises before importing
|
||||
vi.mock("node:readline/promises", () => ({
|
||||
createInterface: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @kb/core before importing
|
||||
vi.mock("@kb/core", () => ({
|
||||
TaskStore: vi.fn(),
|
||||
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
|
||||
COLUMN_LABELS: {
|
||||
triage: "Triage",
|
||||
todo: "Todo",
|
||||
"in-progress": "In Progress",
|
||||
"in-review": "In Review",
|
||||
done: "Done",
|
||||
archived: "Archived",
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore } from "@kb/core";
|
||||
import { runTaskSteer } from "../commands/task.js";
|
||||
|
||||
describe("runTaskSteer", () => {
|
||||
let mockConsoleLog: ReturnType<typeof vi.spyOn>;
|
||||
let mockConsoleError: ReturnType<typeof vi.spyOn>;
|
||||
const mockQuestion = vi.fn();
|
||||
const mockClose = vi.fn();
|
||||
const mockAddSteeringComment = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
mockConsoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockQuestion.mockReset();
|
||||
(createInterface as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
question: mockQuestion,
|
||||
close: mockClose,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockConsoleLog.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
|
||||
function setupTaskStoreMock(overrides: Record<string, unknown> = {}) {
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
addSteeringComment: mockAddSteeringComment,
|
||||
...overrides,
|
||||
}));
|
||||
}
|
||||
|
||||
it("adds steering comment with message argument", async () => {
|
||||
setupTaskStoreMock();
|
||||
mockAddSteeringComment.mockResolvedValueOnce({
|
||||
id: "KB-001",
|
||||
title: "Test Task",
|
||||
});
|
||||
|
||||
await runTaskSteer("KB-001", "Focus on error handling");
|
||||
|
||||
expect(mockAddSteeringComment).toHaveBeenCalledWith("KB-001", "Focus on error handling", "user");
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Steering comment added to KB-001")
|
||||
);
|
||||
});
|
||||
|
||||
it("reads message from stdin when not provided as argument", async () => {
|
||||
setupTaskStoreMock();
|
||||
mockAddSteeringComment.mockResolvedValueOnce({
|
||||
id: "KB-002",
|
||||
title: "Another Task",
|
||||
});
|
||||
|
||||
mockQuestion.mockResolvedValueOnce("This is a steering comment from stdin");
|
||||
|
||||
await runTaskSteer("KB-002", undefined);
|
||||
|
||||
expect(mockQuestion).toHaveBeenCalledWith("Message: ");
|
||||
expect(mockAddSteeringComment).toHaveBeenCalledWith("KB-002", "This is a steering comment from stdin", "user");
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects messages longer than 2000 characters", async () => {
|
||||
setupTaskStoreMock();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`Process.exit called with ${code}`);
|
||||
});
|
||||
|
||||
const longMessage = "a".repeat(2001);
|
||||
|
||||
await expect(runTaskSteer("KB-003", longMessage)).rejects.toThrow();
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Message must be between 1 and 2000 characters")
|
||||
);
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("rejects empty messages", async () => {
|
||||
setupTaskStoreMock();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`Process.exit called with ${code}`);
|
||||
});
|
||||
|
||||
await expect(runTaskSteer("KB-004", "")).rejects.toThrow();
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Message is required")
|
||||
);
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("rejects whitespace-only messages", async () => {
|
||||
setupTaskStoreMock();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`Process.exit called with ${code}`);
|
||||
});
|
||||
|
||||
await expect(runTaskSteer("KB-005", " ")).rejects.toThrow();
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Message is required")
|
||||
);
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles task not found error (ENOENT)", async () => {
|
||||
setupTaskStoreMock();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`Process.exit called with ${code}`);
|
||||
});
|
||||
|
||||
const error = new Error("Task not found") as Error & { code: string };
|
||||
error.code = "ENOENT";
|
||||
mockAddSteeringComment.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(runTaskSteer("KB-999", "Some message")).rejects.toThrow();
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Task not found: KB-999")
|
||||
);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows success output with preview for short messages", async () => {
|
||||
setupTaskStoreMock();
|
||||
mockAddSteeringComment.mockResolvedValueOnce({
|
||||
id: "KB-006",
|
||||
title: "Short Message Task",
|
||||
});
|
||||
|
||||
await runTaskSteer("KB-006", "Short comment");
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Short comment")
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates long messages in success preview", async () => {
|
||||
setupTaskStoreMock();
|
||||
mockAddSteeringComment.mockResolvedValueOnce({
|
||||
id: "KB-007",
|
||||
title: "Long Message Task",
|
||||
});
|
||||
|
||||
const longMessage = "a".repeat(100);
|
||||
await runTaskSteer("KB-007", longMessage);
|
||||
|
||||
// Should show first 60 chars + ellipsis
|
||||
const expectedPreview = "a".repeat(60) + "…";
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining(expectedPreview)
|
||||
);
|
||||
});
|
||||
|
||||
it("trims whitespace from messages", async () => {
|
||||
setupTaskStoreMock();
|
||||
mockAddSteeringComment.mockResolvedValueOnce({
|
||||
id: "KB-008",
|
||||
title: "Trim Test Task",
|
||||
});
|
||||
|
||||
await runTaskSteer("KB-008", " Some message with whitespace ");
|
||||
|
||||
expect(mockAddSteeringComment).toHaveBeenCalledWith("KB-008", "Some message with whitespace", "user");
|
||||
});
|
||||
|
||||
it("accepts messages at boundary lengths (1 and 2000 chars)", async () => {
|
||||
setupTaskStoreMock();
|
||||
mockAddSteeringComment.mockResolvedValueOnce({
|
||||
id: "KB-009",
|
||||
title: "Boundary Test",
|
||||
});
|
||||
|
||||
// Test 1 character
|
||||
await runTaskSteer("KB-009", "x");
|
||||
expect(mockAddSteeringComment).toHaveBeenCalledWith("KB-009", "x", "user");
|
||||
|
||||
// Reset mock for next test
|
||||
vi.clearAllMocks();
|
||||
setupTaskStoreMock();
|
||||
mockAddSteeringComment.mockResolvedValueOnce({
|
||||
id: "KB-010",
|
||||
title: "Boundary Test 2",
|
||||
});
|
||||
|
||||
// Test exactly 2000 characters
|
||||
const exact2000 = "b".repeat(2000);
|
||||
await runTaskSteer("KB-010", exact2000);
|
||||
expect(mockAddSteeringComment).toHaveBeenCalledWith("KB-010", exact2000, "user");
|
||||
});
|
||||
|
||||
it("rethrows non-ENOENT errors", async () => {
|
||||
setupTaskStoreMock();
|
||||
|
||||
const error = new Error("Database error");
|
||||
mockAddSteeringComment.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(runTaskSteer("KB-011", "Message")).rejects.toThrow("Database error");
|
||||
});
|
||||
|
||||
it("treats empty string as validation error, not prompt trigger", async () => {
|
||||
setupTaskStoreMock();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`Process.exit called with ${code}`);
|
||||
});
|
||||
|
||||
// Empty string as argument is a validation error, not a prompt trigger
|
||||
await expect(runTaskSteer("KB-012", "")).rejects.toThrow();
|
||||
|
||||
// Should NOT prompt, should error instead
|
||||
expect(mockQuestion).not.toHaveBeenCalled();
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Message is required")
|
||||
);
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -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, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry } = await import("./commands/task.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskSteer } = await import("./commands/task.js");
|
||||
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
|
||||
|
||||
@@ -69,6 +69,7 @@ Usage:
|
||||
kb task attach <id> <file> Attach a file to a task
|
||||
kb task pause <id> Pause a task (stops all automation)
|
||||
kb task unpause <id> Unpause a task (resumes automation)
|
||||
kb task steer <id> [message] Add steering comment (prompts if message omitted)
|
||||
kb task retry <id> Retry a failed task (clears error, moves to todo)
|
||||
kb task import <owner/repo> [opts] Import GitHub issues as tasks
|
||||
kb settings Show current kb configuration
|
||||
@@ -286,6 +287,13 @@ async function main() {
|
||||
await runTaskUnpause(id);
|
||||
break;
|
||||
}
|
||||
case "steer": {
|
||||
const id = args[2];
|
||||
const message = args.slice(3).join(" ");
|
||||
if (!id) { console.error("Usage: kb task steer <id> [message]"); process.exit(1); }
|
||||
await runTaskSteer(id, message || undefined);
|
||||
break;
|
||||
}
|
||||
case "retry": {
|
||||
const id = args[2];
|
||||
if (!id) {
|
||||
|
||||
@@ -891,6 +891,49 @@ export async function runTaskImportFromGitHub(
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskSteer(id: string, message?: string) {
|
||||
const store = await getStore();
|
||||
|
||||
// Get message interactively if not provided as argument
|
||||
let text = message;
|
||||
if (text === undefined) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
text = await rl.question("Message: ");
|
||||
rl.close();
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!text || text.trim().length === 0) {
|
||||
console.error("Error: Message is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length > 2000) {
|
||||
console.error("Error: Message must be between 1 and 2000 characters");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Add steering comment
|
||||
let task;
|
||||
try {
|
||||
task = await store.addSteeringComment(id, trimmed, "user");
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
console.error(`Error: Task not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Show success with preview
|
||||
const preview = trimmed.length > 60 ? trimmed.slice(0, 60) + "…" : trimmed;
|
||||
console.log();
|
||||
console.log(` ✓ Steering comment added to ${task.id}`);
|
||||
console.log(` "${preview}"`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ── Planning Mode ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Helper to display thinking indicator */
|
||||
|
||||
Reference in New Issue
Block a user