test(KB-601): add test for pause/unpause with missing directory

This commit is contained in:
gsxdsm
2026-03-31 14:43:55 -07:00
parent a6acf8a5f4
commit ace0732a1d
21 changed files with 1090 additions and 24 deletions

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": patch
---
Add general task comments in the dashboard and CLI, and surface richer merge details for completed tasks.

View File

@@ -88,6 +88,8 @@ fn task archive KB-001 # Archive a done task
fn task unarchive KB-001 # Restore an archived task
fn task delete KB-001 [--force] # Delete a task
fn task retry KB-001 # Retry a failed task
fn task comment KB-001 "Looks good" # Add a general task comment
fn task comments KB-001 # List task comments
fn task steer KB-001 "Use TypeScript" # Add steering comment
fn task pause KB-001 # Pause automation for task
fn task unpause KB-001 # Resume automation for task
@@ -500,6 +502,29 @@ When a task has a linked PR, Fusion automatically monitors it for new review com
Uses `gh` CLI authentication when available, falls back to `GITHUB_TOKEN` if set.
## Task Comments vs Steering Comments
Fusion now supports two distinct kinds of discussion on a task:
- **Task comments** — General collaboration notes for humans. Use these for questions, decisions, progress notes, or context you want preserved on the task. You can add them from the dashboard Comments tab or from the CLI with `fn task comment <id> "message"`.
- **Steering comments** — Execution guidance aimed at the AI worker. These are used for actionable direction like “change this approach” or “use TypeScript here,” and are also populated automatically from actionable PR review feedback.
Use task comments for conversation. Use steering comments when you want to influence implementation behavior.
## Merge Details
When a task is merged successfully, Fusion now stores richer merge metadata on the completed task and shows it in the task detail view. This includes:
- merge commit SHA
- files changed count
- insertions and deletions
- merge timestamp
- merge commit message
- linked PR number when available
- merge confirmation status
This gives completed tasks a clearer audit trail in both the board UI and stored task metadata.
## Workflow Steps
Workflow steps are reusable quality gates that run after task implementation but before the task moves to in-review.

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, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskSteer, runTaskPrCreate } = 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, runTaskComment, runTaskComments, runTaskSteer, runTaskPrCreate } = 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,8 @@ Usage:
fn task attach <id> <file> Attach a file to a task
fn task pause <id> Pause a task (stops all automation)
fn task unpause <id> Unpause a task (resumes automation)
fn task comment <id> [message] Add task comment (prompts if message omitted)
fn task comments <id> List task comments
fn task steer <id> [message] Add steering comment (prompts if message omitted)
fn task retry <id> Retry a failed task (clears error, moves to todo)
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
@@ -296,6 +298,25 @@ async function main() {
await runTaskUnpause(id);
break;
}
case "comment": {
const id = args[2];
if (!id) { console.error("Usage: fn task comment <id> [message] [--author <name>]"); process.exit(1); }
const authorIdx = args.indexOf("--author");
const author = authorIdx !== -1 && authorIdx + 1 < args.length ? args[authorIdx + 1] : undefined;
const messageParts = args.slice(3).filter((arg, index, arr) => {
const absoluteIndex = index + 3;
return absoluteIndex !== authorIdx && absoluteIndex !== authorIdx + 1;
});
const message = messageParts.join(" ");
await runTaskComment(id, message || undefined, author || process.env.USER || "user");
break;
}
case "comments": {
const id = args[2];
if (!id) { console.error("Usage: fn task comments <id>"); process.exit(1); }
await runTaskComments(id);
break;
}
case "steer": {
const id = args[2];
const message = args.slice(3).join(" ");

View File

@@ -52,7 +52,7 @@ vi.mock("@fusion/core/gh-cli", () => ({
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskPrCreate, type LogsOptions } from "./task.js";
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, type LogsOptions } from "./task.js";
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli";
import { GitHubClient } from "@fusion/dashboard";
@@ -1204,6 +1204,44 @@ describe("runTaskDelete", () => {
// --- Retry Tests ---
describe("runTaskComment", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("adds a task comment with explicit author", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
addTaskComment: vi.fn().mockResolvedValue(makeTask({ comments: [{ id: "c1", text: "Hello", author: "alice", createdAt: new Date().toISOString() }] })),
}));
await runTaskComment("KB-001", "Hello", "alice");
const store = (TaskStore as unknown as ReturnType<typeof vi.fn>).mock.results.at(-1)?.value;
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "alice");
expect(logSpy).toHaveBeenCalledWith(" ✓ Comment added to KB-001");
});
it("lists task comments", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ comments: [{ id: "c1", text: "Hello", author: "alice", createdAt: "2026-01-01T00:00:00.000Z" }] })),
}));
await runTaskComments("KB-001");
expect(logSpy).toHaveBeenCalledWith(" Comments for KB-001:");
});
});
describe("runTaskRetry", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let mockGetTask: ReturnType<typeof vi.fn>;

View File

@@ -895,6 +895,58 @@ export async function runTaskImportFromGitHub(
console.log();
}
export async function runTaskComment(id: string, message?: string, author = "user") {
const store = await getStore();
let text = message;
if (text === undefined) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
text = await rl.question("Comment: ");
rl.close();
}
if (!text || text.trim().length === 0) {
console.error("Error: Comment is required");
process.exit(1);
}
const trimmed = text.trim();
if (trimmed.length > 2000) {
console.error("Error: Comment must be between 1 and 2000 characters");
process.exit(1);
}
const task = await store.addTaskComment(id, trimmed, author || "user");
const latestComment = task.comments?.[task.comments.length - 1];
console.log();
console.log(` ✓ Comment added to ${task.id}`);
if (latestComment) {
console.log(` ID: ${latestComment.id}`);
}
console.log();
}
export async function runTaskComments(id: string) {
const store = await getStore();
const task = await store.getTask(id);
const comments = task.comments || [];
console.log();
if (comments.length === 0) {
console.log(` No comments on ${id}`);
console.log();
return;
}
console.log(` Comments for ${id}:`);
for (const comment of comments) {
console.log(` ${comment.id} · ${comment.author} · ${new Date(comment.updatedAt || comment.createdAt).toLocaleString()}`);
console.log(` ${comment.text}`);
}
console.log();
}
export async function runTaskSteer(id: string, message?: string) {
const store = await getStore();

View File

@@ -166,11 +166,11 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments, steeringComments,
workflowStepResults, prInfo, issueInfo, breakIntoSubtasks,
enabledWorkflowSteps
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`);
@@ -214,9 +214,11 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
toJson(task.log || []),
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
toJsonNullable(task.mergeDetails),
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),
);

View File

@@ -92,9 +92,11 @@ CREATE TABLE IF NOT EXISTS tasks (
log TEXT DEFAULT '[]',
attachments TEXT DEFAULT '[]',
steeringComments TEXT DEFAULT '[]',
comments TEXT DEFAULT '[]',
workflowStepResults TEXT DEFAULT '[]',
prInfo TEXT,
issueInfo TEXT,
mergeDetails TEXT,
breakIntoSubtasks INTEGER DEFAULT 0,
enabledWorkflowSteps TEXT DEFAULT '[]'
);

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent } from "./types.js";
export { AgentStore } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js";
export { TaskStore } from "./store.js";

View File

@@ -1017,6 +1017,72 @@ describe("TaskStore", () => {
});
});
describe("task comments", () => {
it("adds a task comment to a task", async () => {
const task = await createTestTask();
const updated = await store.addTaskComment(task.id, "Please review this", "alice");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Please review this");
expect(updated.comments![0].author).toBe("alice");
expect(updated.comments![0].id).toBeDefined();
expect(updated.comments![0].createdAt).toBeDefined();
expect(updated.comments![0].updatedAt).toBeUndefined();
});
it("updates an existing task comment", async () => {
const task = await createTestTask();
const added = await store.addTaskComment(task.id, "First draft", "alice");
const commentId = added.comments![0].id;
const updated = await store.updateTaskComment(task.id, commentId, "Updated draft");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Updated draft");
expect(updated.comments![0].updatedAt).toBeDefined();
expect(updated.log.some((entry) => entry.action === "Comment updated")).toBe(true);
});
it("deletes a task comment", async () => {
const task = await createTestTask();
const added = await store.addTaskComment(task.id, "Disposable", "alice");
const commentId = added.comments![0].id;
const updated = await store.deleteTaskComment(task.id, commentId);
expect(updated.comments).toBeUndefined();
expect(updated.log.some((entry) => entry.action === "Comment deleted")).toBe(true);
});
it("throws when updating a missing task comment", async () => {
const task = await createTestTask();
await expect(store.updateTaskComment(task.id, "missing", "Nope")).rejects.toThrow(
`Comment missing not found on task ${task.id}`,
);
});
it("throws when deleting a missing task comment", async () => {
const task = await createTestTask();
await expect(store.deleteTaskComment(task.id, "missing")).rejects.toThrow(
`Comment missing not found on task ${task.id}`,
);
});
it("persists task comments independently from steering comments", async () => {
const task = await createTestTask();
await store.addTaskComment(task.id, "General note", "alice");
await store.addSteeringComment(task.id, "Execution note");
const reopened = await store.getTask(task.id);
expect(reopened.comments).toHaveLength(1);
expect(reopened.comments![0].text).toBe("General note");
expect(reopened.steeringComments).toHaveLength(1);
expect(reopened.steeringComments![0].text).toBe("Execution note");
});
});
describe("addSteeringComment", () => {
it("adds a steering comment to a task", async () => {
const task = await createTestTask();
@@ -1226,6 +1292,43 @@ describe("TaskStore", () => {
});
});
describe("task comments and merge details types", () => {
it("keeps task comments distinct from steering comments on new tasks", async () => {
const task = await createTestTask();
const reopened = await store.getTask(task.id);
expect(reopened.comments).toBeUndefined();
expect(reopened.steeringComments).toBeUndefined();
});
it("supports the task comment and merge details shapes", async () => {
const comment: NonNullable<Task["comments"]>[number] = {
id: `comment-${Date.now()}`,
text: "Looks good",
author: "alice",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mergeDetails: NonNullable<Task["mergeDetails"]> = {
commitSha: "abc123def456",
filesChanged: 3,
insertions: 10,
deletions: 2,
mergeCommitMessage: "feat(KB-001): merge kb/kb-001",
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
prNumber: 42,
};
const taskShape: Pick<Task, "comments" | "mergeDetails"> = {
comments: [comment],
mergeDetails,
};
expect(taskShape.comments).toEqual([comment]);
expect(taskShape.mergeDetails).toEqual(mergeDetails);
});
});
describe("updatePrInfo", () => {
it("adds PR info to a task without existing PR", async () => {
const task = await createTestTask();
@@ -3169,6 +3272,38 @@ describe("TaskStore", () => {
expect(logs[0].type).toBe("task:deleted");
});
it("captures merge details when merging a task", async () => {
const task = await store.createTask({ description: "Test merge details" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
worktree: "/tmp/test-worktree",
});
const { execSync } = await import("node:child_process");
try {
execSync(`git checkout -b kb/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
} catch {
return;
}
try {
const result = await store.mergeTask(task.id);
expect(result.mergeConfirmed ?? result.merged).toBeDefined();
expect(result.task.mergeDetails).toBeDefined();
if (result.merged) {
expect(result.task.mergeDetails?.commitSha).toBeTruthy();
expect(result.task.mergeDetails?.mergeCommitMessage).toContain(task.id);
expect(result.task.mergeDetails?.mergedAt).toBeDefined();
}
} catch {
// merge may fail depending on repo state; skip strict assertions in that case
}
});
it("records activity on task:merged", async () => {
const task = await store.createTask({ description: "Test merged event" });
await store.moveTask(task.id, "todo");

View File

@@ -141,9 +141,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
log: fromJson<import("./types.js").TaskLogEntry[]>(row.log) || [],
attachments: (() => { const a = fromJson<TaskAttachment[]>(row.attachments); return a && a.length > 0 ? a : undefined; })(),
steeringComments: (() => { const s = fromJson<import("./types.js").SteeringComment[]>(row.steeringComments); return s && s.length > 0 ? s : undefined; })(),
comments: (() => { const c = fromJson<import("./types.js").TaskComment[]>(row.comments); return c && c.length > 0 ? c : undefined; })(),
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
mergeDetails: fromJson<import("./types.js").MergeDetails>(row.mergeDetails),
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
};
@@ -160,11 +162,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments, steeringComments,
workflowStepResults, prInfo, issueInfo, breakIntoSubtasks,
enabledWorkflowSteps
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`).run(
task.id,
@@ -196,9 +198,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJson(task.log || []),
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
toJsonNullable(task.mergeDetails),
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),
);
@@ -1162,6 +1166,58 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
private collectMergeDetails(id: string, branch: string, task: Task, commitMessage: string): import("./types.js").MergeDetails {
const mergedAt = new Date().toISOString();
let commitSha: string | undefined;
let filesChanged: number | undefined;
let insertions: number | undefined;
let deletions: number | undefined;
try {
commitSha = execSync("git rev-parse HEAD", {
cwd: this.rootDir,
stdio: "pipe",
encoding: "utf-8",
}).trim() || undefined;
} catch {
commitSha = undefined;
}
try {
const statsOutput = execSync("git show --shortstat --format= HEAD", {
cwd: this.rootDir,
stdio: "pipe",
encoding: "utf-8",
}).trim();
const normalized = statsOutput.replace(/\n/g, " ");
const filesMatch = normalized.match(/(\d+) files? changed/);
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/);
filesChanged = filesMatch ? Number.parseInt(filesMatch[1], 10) : 0;
insertions = insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0;
deletions = deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0;
} catch {
filesChanged = undefined;
insertions = undefined;
deletions = undefined;
}
return {
commitSha,
filesChanged,
insertions,
deletions,
mergeCommitMessage: commitMessage,
mergedAt,
mergeConfirmed: true,
prNumber: task.prInfo?.number,
resolutionStrategy: task.mergeDetails?.resolutionStrategy,
resolutionMethod: task.mergeDetails?.resolutionMethod,
attemptsMade: task.mergeDetails?.attemptsMade,
autoResolvedCount: task.mergeDetails?.autoResolvedCount,
};
}
/**
* Merge an in-review task's branch into the current branch,
* clean up the worktree, and move the task to done.
@@ -1196,6 +1252,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} catch {
// No branch — might have been manually merged. Just move to done.
result.error = `Branch '${branch}' not found — moving to done without merge`;
task.mergeDetails = {
mergedAt: new Date().toISOString(),
mergeConfirmed: false,
prNumber: task.prInfo?.number,
};
await this.moveToDone(task, dir);
result.task = { ...task, column: "done" };
this.emit("task:merged", result);
@@ -1203,16 +1264,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
// 2. Merge the branch
const mergeCommitMessage = `feat(${id}): merge ${branch}`;
try {
execSync(`git merge --squash "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
execSync(`git commit --no-edit -m "feat(${id}): merge ${branch}"`, {
execSync(`git commit --no-edit -m "${mergeCommitMessage}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.merged = true;
const mergeDetails = this.collectMergeDetails(id, branch, task, mergeCommitMessage);
task.mergeDetails = mergeDetails;
Object.assign(result, mergeDetails);
} catch (err: any) {
// Squash conflict — reset and report
try {
@@ -1806,6 +1871,95 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.emit("agent:log", entry);
}
async addTaskComment(id: string, text: string, author: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
if (!task.log) {
task.log = [];
}
const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const comment: import("./types.js").TaskComment = {
id: commentId,
text,
author,
createdAt: new Date().toISOString(),
};
if (!task.comments) {
task.comments = [];
}
task.comments.push(comment);
task.updatedAt = new Date().toISOString();
task.log.push({
timestamp: task.updatedAt,
action: `Comment added by ${author}`,
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async updateTaskComment(id: string, commentId: string, text: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const comments = task.comments || [];
const comment = comments.find((entry) => entry.id === commentId);
if (!comment) {
throw new Error(`Comment ${commentId} not found on task ${id}`);
}
comment.text = text;
comment.updatedAt = new Date().toISOString();
task.comments = comments;
task.updatedAt = comment.updatedAt;
task.log.push({
timestamp: task.updatedAt,
action: "Comment updated",
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async deleteTaskComment(id: string, commentId: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const currentComments = task.comments || [];
const nextComments = currentComments.filter((entry) => entry.id !== commentId);
if (nextComments.length === currentComments.length) {
throw new Error(`Comment ${commentId} not found on task ${id}`);
}
task.comments = nextComments.length > 0 ? nextComments : undefined;
task.updatedAt = new Date().toISOString();
task.log.push({
timestamp: task.updatedAt,
action: "Comment deleted",
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/**
* Add a steering comment to a task.
* Steering comments are user-provided feedback injected into the AI execution context.

View File

@@ -333,6 +333,34 @@ export interface SteeringComment {
author: "user" | "agent";
}
export interface TaskComment {
id: string;
text: string;
author: string;
createdAt: string;
updatedAt?: string;
}
export interface TaskCommentInput {
text: string;
author: string;
}
export interface MergeDetails {
commitSha?: string;
filesChanged?: number;
insertions?: number;
deletions?: number;
mergeCommitMessage?: string;
mergedAt?: string;
mergeConfirmed?: boolean;
prNumber?: number;
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs";
attemptsMade?: 1 | 2 | 3;
autoResolvedCount?: number;
}
export interface Task {
id: string;
title?: string;
@@ -360,8 +388,10 @@ export interface Task {
baseBranch?: string;
attachments?: TaskAttachment[];
steeringComments?: SteeringComment[];
comments?: TaskComment[];
/** PR information for tasks linked to GitHub pull requests */
prInfo?: PrInfo;
mergeDetails?: MergeDetails;
/** Issue information for tasks imported from GitHub issues */
issueInfo?: IssueInfo;
log: TaskLogEntry[];
@@ -723,21 +753,13 @@ export interface BoardConfig {
nextWorkflowStepId?: number;
}
export interface MergeResult {
export interface MergeResult extends MergeDetails {
task: Task;
branch: string;
merged: boolean;
worktreeRemoved: boolean;
branchDeleted: boolean;
error?: string;
/** Strategy that successfully resolved the merge, if any */
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
/** Alias for resolutionStrategy — how conflicts were resolved (for metrics/debugging) */
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs";
/** Number of retry attempts made (1 = first attempt succeeded, 2-3 = retries needed) */
attemptsMade?: 1 | 2 | 3;
/** Number of files auto-resolved (for tracking mixed resolution scenarios) */
autoResolvedCount?: number;
}
export const COLUMN_LABELS: Record<Column, string> = {

View File

@@ -9,6 +9,10 @@ import {
logoutProvider,
fetchModels,
addSteeringComment,
addTaskComment,
updateTaskComment,
deleteTaskComment,
fetchTaskComments,
fetchGitRemotes,
refineTask,
fetchBatchStatus,
@@ -134,6 +138,75 @@ describe("updateTask", () => {
});
});
describe("task comments api", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const FAKE_TASK: Task = {
id: "KB-001",
description: "Test",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
};
it("fetches task comments", async () => {
const comments = FAKE_TASK.comments!;
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, comments));
const result = await fetchTaskComments("KB-001");
expect(result).toEqual(comments);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
headers: { "Content-Type": "application/json" },
});
});
it("adds a task comment", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
const result = await addTaskComment("KB-001", "Hello", "user");
expect(result).toEqual(FAKE_TASK);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ text: "Hello", author: "user" }),
});
});
it("updates a task comment", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
await updateTaskComment("KB-001", "c1", "Updated");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ text: "Updated" }),
});
});
it("deletes a task comment", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
await deleteTaskComment("KB-001", "c1");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
headers: { "Content-Type": "application/json" },
method: "DELETE",
});
});
});
describe("fetchModels", () => {
const originalFetch = globalThis.fetch;

View File

@@ -2,6 +2,7 @@ import type {
Task,
TaskDetail,
TaskAttachment,
TaskComment,
TaskCreateInput,
AgentLogEntry,
Column,
@@ -254,6 +255,30 @@ export function fetchSessionFiles(taskId: string): Promise<string[]> {
return api<string[]>(`/tasks/${taskId}/session-files`);
}
export function fetchTaskComments(id: string): Promise<TaskComment[]> {
return api<TaskComment[]>(`/tasks/${id}/comments`);
}
export function addTaskComment(id: string, text: string, author?: string): Promise<Task> {
return api<Task>(`/tasks/${id}/comments`, {
method: "POST",
body: JSON.stringify({ text, author }),
});
}
export function updateTaskComment(id: string, commentId: string, text: string): Promise<Task> {
return api<Task>(`/tasks/${id}/comments/${commentId}`, {
method: "PATCH",
body: JSON.stringify({ text }),
});
}
export function deleteTaskComment(id: string, commentId: string): Promise<Task> {
return api<Task>(`/tasks/${id}/comments/${commentId}`, {
method: "DELETE",
});
}
export function addSteeringComment(id: string, text: string): Promise<Task> {
return api<Task>(`/tasks/${id}/steer`, {
method: "POST",

View File

@@ -0,0 +1,74 @@
import type { Task } from "@kb/core";
interface MergeDetailsProps {
task: Task;
}
function shortSha(sha?: string): string {
if (!sha) return "Unknown";
return sha.slice(0, 7);
}
export function MergeDetails({ task }: MergeDetailsProps) {
if (task.column !== "done" || !task.mergeDetails) {
return null;
}
const details = task.mergeDetails;
return (
<div className="detail-section">
<h4>Merge Details</h4>
<div className="pr-card" style={{ border: "1px solid var(--border, #333)", borderRadius: 8, padding: 12 }}>
<div className="detail-log-entry">
<div className="detail-log-header">
<span className="detail-log-action">Status</span>
<span className="detail-log-outcome">{details.mergeConfirmed === false ? "Recorded without local merge confirmation" : "Merged successfully"}</span>
</div>
</div>
<div className="detail-log-entry">
<div className="detail-log-header">
<span className="detail-log-action">Commit</span>
<span className="detail-log-outcome">{shortSha(details.commitSha)}</span>
</div>
</div>
<div className="detail-log-entry">
<div className="detail-log-header">
<span className="detail-log-action">Files changed</span>
<span className="detail-log-outcome">{details.filesChanged ?? 0}</span>
</div>
</div>
<div className="detail-log-entry">
<div className="detail-log-header">
<span className="detail-log-action">Insertions / Deletions</span>
<span className="detail-log-outcome">+{details.insertions ?? 0} / -{details.deletions ?? 0}</span>
</div>
</div>
{details.mergedAt ? (
<div className="detail-log-entry">
<div className="detail-log-header">
<span className="detail-log-action">Merged at</span>
<span className="detail-log-outcome">{new Date(details.mergedAt).toLocaleString()}</span>
</div>
</div>
) : null}
{details.prNumber ? (
<div className="detail-log-entry">
<div className="detail-log-header">
<span className="detail-log-action">PR</span>
<span className="detail-log-outcome">#{details.prNumber}</span>
</div>
</div>
) : null}
{details.mergeCommitMessage ? (
<div className="detail-log-entry">
<div className="detail-log-header">
<span className="detail-log-action">Message</span>
</div>
<div className="detail-log-outcome">{details.mergeCommitMessage}</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useMemo, useState } from "react";
import type { Task, TaskComment } from "@kb/core";
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
import type { ToastType } from "../hooks/useToast";
interface TaskCommentsProps {
task: Task;
onTaskUpdated?: (task: Task) => void;
addToast: (message: string, type?: ToastType) => void;
currentAuthor?: string;
}
function formatCommentTimestamp(comment: TaskComment): string {
const timestamp = comment.updatedAt || comment.createdAt;
const label = new Date(timestamp).toLocaleString();
return comment.updatedAt ? `${label} (edited)` : label;
}
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user" }: TaskCommentsProps) {
const [draft, setDraft] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const comments = useMemo(() => task.comments || [], [task.comments]);
async function handleAddComment() {
const text = draft.trim();
if (!text) return;
setSubmitting(true);
try {
const updated = await addTaskComment(task.id, text, currentAuthor);
setDraft("");
onTaskUpdated?.(updated);
addToast("Comment added", "success");
} catch (error: any) {
addToast(error.message || "Failed to add comment", "error");
} finally {
setSubmitting(false);
}
}
async function handleSaveEdit(commentId: string) {
const text = editingText.trim();
if (!text) return;
setSubmitting(true);
try {
const updated = await updateTaskComment(task.id, commentId, text);
setEditingId(null);
setEditingText("");
onTaskUpdated?.(updated);
addToast("Comment updated", "success");
} catch (error: any) {
addToast(error.message || "Failed to update comment", "error");
} finally {
setSubmitting(false);
}
}
async function handleDelete(commentId: string) {
setDeletingId(commentId);
try {
const updated = await deleteTaskComment(task.id, commentId);
onTaskUpdated?.(updated);
addToast("Comment deleted", "success");
} catch (error: any) {
addToast(error.message || "Failed to delete comment", "error");
} finally {
setDeletingId(null);
}
}
return (
<div className="detail-section">
<h4>Comments</h4>
{comments.length === 0 ? (
<div className="detail-log-empty">No comments yet.</div>
) : (
<div className="detail-activity-list">
{comments.map((comment) => {
const canEdit = comment.author === currentAuthor;
const isEditing = editingId === comment.id;
return (
<div key={comment.id} className="detail-log-entry">
<div className="detail-log-header" style={{ justifyContent: "space-between", gap: 12 }}>
<div>
<strong>{comment.author}</strong>
<span className="detail-log-timestamp" style={{ marginLeft: 8 }}>
{formatCommentTimestamp(comment)}
</span>
</div>
{canEdit && !isEditing ? (
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-sm" onClick={() => {
setEditingId(comment.id);
setEditingText(comment.text);
}}>
Edit
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => void handleDelete(comment.id)}
disabled={deletingId === comment.id}
>
{deletingId === comment.id ? "Deleting…" : "Delete"}
</button>
</div>
) : null}
</div>
{isEditing ? (
<div style={{ display: "grid", gap: 8, marginTop: 8 }}>
<textarea
value={editingText}
onChange={(event) => setEditingText(event.target.value)}
rows={3}
className="spec-editor-feedback"
/>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
className="btn btn-sm"
onClick={() => {
setEditingId(null);
setEditingText("");
}}
disabled={submitting}
>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleSaveEdit(comment.id)}
disabled={submitting || !editingText.trim()}
>
Save
</button>
</div>
</div>
) : (
<div className="detail-log-outcome" style={{ whiteSpace: "pre-wrap" }}>
{comment.text}
</div>
)}
</div>
);
})}
</div>
)}
<div style={{ display: "grid", gap: 8, marginTop: 12 }}>
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={3}
placeholder="Add a comment"
className="spec-editor-feedback"
/>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button className="btn btn-primary btn-sm" onClick={() => void handleAddComment()} disabled={submitting || !draft.trim()}>
{submitting ? "Posting…" : "Add Comment"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -11,6 +11,8 @@ import { AgentLogViewer } from "./AgentLogViewer";
import { SteeringTab } from "./SteeringTab";
import { ModelSelectorTab } from "./ModelSelectorTab";
import { PrSection } from "./PrSection";
import { TaskComments } from "./TaskComments";
import { MergeDetails } from "./MergeDetails";
interface ModelSelection {
provider?: string;
@@ -103,7 +105,7 @@ export function TaskDetailModal({
addToast,
githubTokenConfigured,
}: TaskDetailModalProps) {
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model">("definition");
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "comments" | "model">("definition");
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
@@ -675,6 +677,12 @@ export function TaskDetailModal({
>
Steering
</button>
<button
className={`detail-tab${activeTab === "comments" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("comments")}
>
Comments
</button>
<button
className={`detail-tab${activeTab === "model" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("model")}
@@ -697,6 +705,8 @@ export function TaskDetailModal({
</div>
) : activeTab === "steering" ? (
<SteeringTab task={task} addToast={addToast} />
) : activeTab === "comments" ? (
<TaskComments task={task} addToast={addToast} />
) : activeTab === "activity" ? (
<div className="detail-section detail-activity">
<h4>Activity</h4>
@@ -733,6 +743,7 @@ export function TaskDetailModal({
</div>
</div>
)}
<MergeDetails task={task} />
<div className="detail-section detail-step-progress">
<h4>Progress</h4>
{task.steps && task.steps.length > 0 ? (

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MergeDetails } from "../MergeDetails";
const makeTask = (overrides: any = {}) => ({
id: "KB-001",
description: "Task",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
});
describe("MergeDetails", () => {
it("renders nothing when task is not done", () => {
const { container } = render(<MergeDetails task={makeTask({ column: "in-review", mergeDetails: { commitSha: "abc1234" } })} />);
expect(container.firstChild).toBeNull();
});
it("renders merge metadata for done task", () => {
render(
<MergeDetails
task={makeTask({
mergeDetails: {
commitSha: "abcdef123456",
filesChanged: 5,
insertions: 10,
deletions: 2,
mergedAt: "2026-01-01T01:00:00.000Z",
prNumber: 42,
mergeCommitMessage: "feat(KB-001): merge kb/kb-001",
mergeConfirmed: true,
},
})}
/>,
);
expect(screen.getByText("Merge Details")).toBeTruthy();
expect(screen.getByText("abcdef1")).toBeTruthy();
expect(screen.getByText("5")).toBeTruthy();
expect(screen.getByText("+10 / -2")).toBeTruthy();
expect(screen.getByText("#42")).toBeTruthy();
expect(screen.getByText("feat(KB-001): merge kb/kb-001")).toBeTruthy();
expect(screen.getByText("Merged successfully")).toBeTruthy();
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TaskComments } from "../TaskComments";
vi.mock("../../api", () => ({
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
}));
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../../api";
const makeTask = (overrides: any = {}) => ({
id: "KB-001",
description: "Task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
});
describe("TaskComments", () => {
it("renders empty state", () => {
render(<TaskComments task={makeTask()} addToast={vi.fn()} />);
expect(screen.getByText("No comments yet.")).toBeTruthy();
});
it("adds a comment", async () => {
const onTaskUpdated = vi.fn();
vi.mocked(addTaskComment).mockResolvedValue(makeTask({ comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] }));
render(<TaskComments task={makeTask()} addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
fireEvent.change(screen.getByPlaceholderText("Add a comment"), { target: { value: "Hello" } });
fireEvent.click(screen.getByText("Add Comment"));
await waitFor(() => expect(addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user"));
expect(onTaskUpdated).toHaveBeenCalled();
});
it("edits own comment", async () => {
const onTaskUpdated = vi.fn();
vi.mocked(updateTaskComment).mockResolvedValue(makeTask({ comments: [{ id: "c1", text: "Updated", author: "user", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:01:00.000Z" }] }));
render(<TaskComments task={makeTask({ comments: [{ id: "c1", text: "Original", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] })} addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
fireEvent.click(screen.getByText("Edit"));
fireEvent.change(screen.getByDisplayValue("Original"), { target: { value: "Updated" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateTaskComment).toHaveBeenCalledWith("KB-001", "c1", "Updated"));
expect(onTaskUpdated).toHaveBeenCalled();
});
it("deletes own comment", async () => {
const onTaskUpdated = vi.fn();
vi.mocked(deleteTaskComment).mockResolvedValue(makeTask({ comments: [] }));
render(<TaskComments task={makeTask({ comments: [{ id: "c1", text: "Original", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] })} addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
fireEvent.click(screen.getByText("Delete"));
await waitFor(() => expect(deleteTaskComment).toHaveBeenCalledWith("KB-001", "c1"));
expect(onTaskUpdated).toHaveBeenCalled();
});
});

View File

@@ -115,6 +115,22 @@ describe("TaskDetailModal", () => {
expect(screen.queryByText("PROMPT.md")).toBeNull();
});
it("renders Comments tab", () => {
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Comments")).toBeTruthy();
});
it("renders Retry button when task status is 'failed'", () => {
render(
<TaskDetailModal
@@ -1149,7 +1165,7 @@ describe("TaskDetailModal", () => {
);
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(5); // Definition, Activity, Agent Log, Steering, Model (Spec combined into Definition)
expect(tabs.length).toBe(6); // Definition, Activity, Agent Log, Steering, Comments, Model
// Tabs should use class-based styling, not inline styles
expect(tabs[0].classList.contains("detail-tab")).toBe(true);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active
@@ -1157,6 +1173,7 @@ describe("TaskDetailModal", () => {
expect(tabs[2].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[3].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[4].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[5].classList.contains("detail-tab-active")).toBe(false);
// Verify no inline padding/fontSize (responsive CSS controls this)
expect((tabs[0] as HTMLElement).style.padding).toBe("");
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
@@ -1634,7 +1651,7 @@ describe("TaskDetailModal", () => {
});
});
it("shows all 5 tabs in correct order (Spec tab removed)", () => {
it("shows all 6 tabs in correct order with comments", () => {
const { container } = render(
<TaskDetailModal
task={makeTask()}
@@ -1648,12 +1665,13 @@ describe("TaskDetailModal", () => {
);
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(5);
expect(tabs.length).toBe(6);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Activity");
expect(tabs[2].textContent).toBe("Agent Log");
expect(tabs[3].textContent).toBe("Steering");
expect(tabs[4].textContent).toBe("Model");
expect(tabs[4].textContent).toBe("Comments");
expect(tabs[5].textContent).toBe("Model");
});
it("shows empty state and Edit button when no prompt", () => {

View File

@@ -52,6 +52,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
@@ -1407,6 +1410,63 @@ describe("Pause/Unpause endpoints", () => {
expect(res.body.error).toBe("not found");
});
describe("task comment routes", () => {
it("GET /tasks/:id/comments — returns task comments", async () => {
const comments = [{ id: "c1", text: "Hello", author: "alice", createdAt: "2026-01-01T00:00:00.000Z" }];
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, comments }),
});
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await GET(app, "/api/tasks/KB-001/comments");
expect(res.status).toBe(200);
expect(res.body).toEqual(comments);
});
it("POST /tasks/:id/comments — adds a task comment", async () => {
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] };
const store = createMockStore({ addTaskComment: vi.fn().mockResolvedValue(updatedTask) });
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "POST", "/api/tasks/KB-001/comments", JSON.stringify({ text: "Hello" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user");
});
it("PATCH /tasks/:id/comments/:commentId — updates a task comment", async () => {
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [{ id: "c1", text: "Updated", author: "user", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:01:00.000Z" }] };
const store = createMockStore({ updateTaskComment: vi.fn().mockResolvedValue(updatedTask) });
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "PATCH", "/api/tasks/KB-001/comments/c1", JSON.stringify({ text: "Updated" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTaskComment).toHaveBeenCalledWith("KB-001", "c1", "Updated");
});
it("DELETE /tasks/:id/comments/:commentId — deletes a task comment", async () => {
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [] };
const store = createMockStore({ deleteTaskComment: vi.fn().mockResolvedValue(updatedTask) });
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "DELETE", "/api/tasks/KB-001/comments/c1");
expect(res.status).toBe(200);
expect(store.deleteTaskComment).toHaveBeenCalledWith("KB-001", "c1");
});
});
describe("POST /tasks/:id/steer", () => {
it("adds a steering comment to a task", async () => {
const mockComment = {

View File

@@ -1654,6 +1654,72 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
router.get("/tasks/:id/comments", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
res.json(task.comments || []);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404 : 500;
res.status(status).json({ error: err.message });
}
});
router.post("/tasks/:id/comments", async (req, res) => {
try {
const { text, author } = req.body;
if (!text || typeof text !== "string") {
res.status(400).json({ error: "text is required and must be a string" });
return;
}
if (text.length === 0 || text.length > 2000) {
res.status(400).json({ error: "text must be between 1 and 2000 characters" });
return;
}
if (author !== undefined && typeof author !== "string") {
res.status(400).json({ error: "author must be a string" });
return;
}
const task = await store.addTaskComment(req.params.id, text, author?.trim() || "user");
res.json(task);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404 : 500;
res.status(status).json({ error: err.message });
}
});
router.patch("/tasks/:id/comments/:commentId", async (req, res) => {
try {
const { text } = req.body;
if (!text || typeof text !== "string") {
res.status(400).json({ error: "text is required and must be a string" });
return;
}
if (text.length === 0 || text.length > 2000) {
res.status(400).json({ error: "text must be between 1 and 2000 characters" });
return;
}
const task = await store.updateTaskComment(req.params.id, req.params.commentId, text);
res.json(task);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404
: err.message?.includes("not found") ? 404
: 500;
res.status(status).json({ error: err.message });
}
});
router.delete("/tasks/:id/comments/:commentId", async (req, res) => {
try {
const task = await store.deleteTaskComment(req.params.id, req.params.commentId);
res.json(task);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404
: err.message?.includes("not found") ? 404
: 500;
res.status(status).json({ error: err.message });
}
});
// Add steering comment to task
router.post("/tasks/:id/steer", async (req, res) => {
try {