feat(KB-003): add task steering feature with UI and prompt injection
- Add SteeringComment type to core types for user steering input - Add addSteeringComment method to TaskStore with persistence - Add POST /api/tasks/:id/steer endpoint for adding steering comments - Add frontend API client and SteeringTab UI component - Inject steering comments into execution prompt for agent guidance - Add comprehensive tests for store, API, UI, and executor components - Include changeset for patch release
This commit is contained in:
5
.changeset/add-task-steering.md
Normal file
5
.changeset/add-task-steering.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add task steering from dashboard — users can now add comments to in-progress tasks that are injected into the AI execution context
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
|
||||||
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel } from "./types.js";
|
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment } from "./types.js";
|
||||||
export { TaskStore } from "./store.js";
|
export { TaskStore } from "./store.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
|
|||||||
@@ -718,6 +718,98 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("addSteeringComment", () => {
|
||||||
|
it("adds a steering comment to a task", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const updated = await store.addSteeringComment(task.id, "Please handle the edge case");
|
||||||
|
|
||||||
|
expect(updated.steeringComments).toHaveLength(1);
|
||||||
|
expect(updated.steeringComments![0].text).toBe("Please handle the edge case");
|
||||||
|
expect(updated.steeringComments![0].author).toBe("user");
|
||||||
|
expect(updated.steeringComments![0].id).toBeDefined();
|
||||||
|
expect(updated.steeringComments![0].createdAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts agent as author", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const updated = await store.addSteeringComment(task.id, "Note from agent", "agent");
|
||||||
|
|
||||||
|
expect(updated.steeringComments).toHaveLength(1);
|
||||||
|
expect(updated.steeringComments![0].author).toBe("agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes steeringComments array if undefined", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
expect(task.steeringComments).toBeUndefined();
|
||||||
|
|
||||||
|
const updated = await store.addSteeringComment(task.id, "First comment");
|
||||||
|
expect(updated.steeringComments).toBeDefined();
|
||||||
|
expect(updated.steeringComments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends multiple comments in order", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.addSteeringComment(task.id, "First comment");
|
||||||
|
await store.addSteeringComment(task.id, "Second comment");
|
||||||
|
await store.addSteeringComment(task.id, "Third comment");
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.steeringComments).toHaveLength(3);
|
||||||
|
expect(fetched.steeringComments![0].text).toBe("First comment");
|
||||||
|
expect(fetched.steeringComments![1].text).toBe("Second comment");
|
||||||
|
expect(fetched.steeringComments![2].text).toBe("Third comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates unique IDs for each comment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const updated1 = await store.addSteeringComment(task.id, "Comment 1");
|
||||||
|
const updated2 = await store.addSteeringComment(task.id, "Comment 2");
|
||||||
|
|
||||||
|
const id1 = updated1.steeringComments![0].id;
|
||||||
|
const id2 = updated2.steeringComments![1].id;
|
||||||
|
expect(id1).not.toBe(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits task:updated event", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t) => events.push(t));
|
||||||
|
|
||||||
|
await store.addSteeringComment(task.id, "Test comment");
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].steeringComments).toHaveLength(1);
|
||||||
|
expect(events[0].steeringComments![0].text).toBe("Test comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists to disk and round-trips correctly", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.addSteeringComment(task.id, "Persisted comment");
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.steeringComments).toHaveLength(1);
|
||||||
|
expect(fetched.steeringComments![0].text).toBe("Persisted comment");
|
||||||
|
expect(fetched.steeringComments![0].author).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds log entry for the action", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const updated = await store.addSteeringComment(task.id, "Comment with log");
|
||||||
|
|
||||||
|
expect(updated.log.some((l) => l.action === "Steering comment added")).toBe(true);
|
||||||
|
expect(updated.log.some((l) => l.outcome === "by user")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates updatedAt timestamp", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const before = task.updatedAt;
|
||||||
|
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
|
||||||
|
|
||||||
|
const updated = await store.addSteeringComment(task.id, "Timestamp test");
|
||||||
|
expect(updated.updatedAt).not.toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseDependenciesFromPrompt", () => {
|
describe("parseDependenciesFromPrompt", () => {
|
||||||
it("returns single dependency from PROMPT.md", async () => {
|
it("returns single dependency from PROMPT.md", async () => {
|
||||||
const task = await store.createTask({ description: "Task with dep" });
|
const task = await store.createTask({ description: "Task with dep" });
|
||||||
|
|||||||
@@ -981,6 +981,48 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
this.emit("agent:log", entry);
|
this.emit("agent:log", entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a steering comment to a task.
|
||||||
|
* Steering comments are user-provided feedback injected into the AI execution context.
|
||||||
|
*/
|
||||||
|
async addSteeringComment(
|
||||||
|
id: string,
|
||||||
|
text: string,
|
||||||
|
author: "user" | "agent" = "user",
|
||||||
|
): Promise<Task> {
|
||||||
|
return this.withTaskLock(id, async () => {
|
||||||
|
const dir = this.taskDir(id);
|
||||||
|
const task = await this.readTaskJson(dir);
|
||||||
|
|
||||||
|
// Generate unique ID: timestamp + random suffix for collision resistance
|
||||||
|
const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
const comment: import("./types.js").SteeringComment = {
|
||||||
|
id: commentId,
|
||||||
|
text,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
author,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!task.steeringComments) {
|
||||||
|
task.steeringComments = [];
|
||||||
|
}
|
||||||
|
task.steeringComments.push(comment);
|
||||||
|
task.updatedAt = new Date().toISOString();
|
||||||
|
task.log.push({
|
||||||
|
timestamp: task.updatedAt,
|
||||||
|
action: "Steering comment added",
|
||||||
|
outcome: `by ${author}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
|
this.emit("task:updated", task);
|
||||||
|
return task;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read all historical agent log entries for a task from its agent log file.
|
* Read all historical agent log entries for a task from its agent log file.
|
||||||
* Returns entries in chronological order (oldest first).
|
* Returns entries in chronological order (oldest first).
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ export interface TaskAttachment {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SteeringComment {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
author: "user" | "agent";
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -73,6 +80,7 @@ export interface Task {
|
|||||||
* dependency's branch instead of HEAD. Cleared after worktree creation. */
|
* dependency's branch instead of HEAD. Cleared after worktree creation. */
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
attachments?: TaskAttachment[];
|
attachments?: TaskAttachment[];
|
||||||
|
steeringComments?: SteeringComment[];
|
||||||
log: TaskLogEntry[];
|
log: TaskLogEntry[];
|
||||||
size?: "S" | "M" | "L";
|
size?: "S" | "M" | "L";
|
||||||
reviewLevel?: number;
|
reviewLevel?: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "./api";
|
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment } from "./api";
|
||||||
import type { Task, TaskDetail } from "@kb/core";
|
import type { Task, TaskDetail } from "@kb/core";
|
||||||
|
|
||||||
const FAKE_DETAIL: TaskDetail = {
|
const FAKE_DETAIL: TaskDetail = {
|
||||||
@@ -211,3 +211,54 @@ describe("logoutProvider", () => {
|
|||||||
await expect(logoutProvider("anthropic")).rejects.toThrow("logout failed");
|
await expect(logoutProvider("anthropic")).rejects.toThrow("logout failed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("addSteeringComment", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const FAKE_TASK: Task = {
|
||||||
|
id: "KB-001",
|
||||||
|
description: "Test",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "1234567890-abc123",
|
||||||
|
text: "Please handle the edge case",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
author: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sends POST with text and returns updated task", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||||
|
|
||||||
|
const result = await addSteeringComment("KB-001", "Please handle the edge case");
|
||||||
|
|
||||||
|
expect(result.id).toBe("KB-001");
|
||||||
|
expect(result.steeringComments).toHaveLength(1);
|
||||||
|
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/steer", {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ text: "Please handle the edge case" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on error response", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(
|
||||||
|
mockFetchResponse(false, { error: "Task not found" })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(addSteeringComment("KB-001", "Test comment")).rejects.toThrow("Task not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ export function fetchAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
|||||||
return api<AgentLogEntry[]>(`/tasks/${taskId}/logs`);
|
return api<AgentLogEntry[]>(`/tasks/${taskId}/logs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addSteeringComment(id: string, text: string): Promise<Task> {
|
||||||
|
return api<Task>(`/tasks/${id}/steer`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Models API ---
|
// --- Models API ---
|
||||||
|
|
||||||
/** Available AI model info returned by the models endpoint */
|
/** Available AI model info returned by the models endpoint */
|
||||||
|
|||||||
169
packages/dashboard/app/components/SteeringTab.tsx
Normal file
169
packages/dashboard/app/components/SteeringTab.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { TaskDetail } from "@kb/core";
|
||||||
|
import { addSteeringComment } from "../api";
|
||||||
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
|
||||||
|
if (diffMin < 1) return "just now";
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SteeringTabProps {
|
||||||
|
task: TaskDetail;
|
||||||
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SteeringTab({ task, addToast }: SteeringTabProps) {
|
||||||
|
const [comments, setComments] = useState(task.steeringComments || []);
|
||||||
|
const [newComment, setNewComment] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const MAX_LENGTH = 2000;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!newComment.trim() || newComment.length > MAX_LENGTH || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const updated = await addSteeringComment(task.id, newComment.trim());
|
||||||
|
setComments(updated.steeringComments || []);
|
||||||
|
setNewComment("");
|
||||||
|
addToast("Steering comment added", "success");
|
||||||
|
} catch (err: any) {
|
||||||
|
addToast(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [task.id, newComment, isSubmitting, addToast]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValid = newComment.trim().length > 0 && newComment.length <= MAX_LENGTH;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>Steering Comments</h4>
|
||||||
|
<p style={{ fontSize: "13px", opacity: 0.7, marginBottom: "12px" }}>
|
||||||
|
Add comments to guide the AI during task execution. These are injected into the execution context.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{comments.length > 0 ? (
|
||||||
|
<div className="detail-activity-list" style={{ marginBottom: "16px" }}>
|
||||||
|
{[...comments].reverse().map((comment) => (
|
||||||
|
<div key={comment.id} className="detail-log-entry">
|
||||||
|
<div className="detail-log-header">
|
||||||
|
<span
|
||||||
|
className="detail-log-timestamp"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "11px",
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
background:
|
||||||
|
comment.author === "user"
|
||||||
|
? "var(--accent-primary, #6366f1)"
|
||||||
|
: "var(--accent-secondary, #8b5cf6)",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comment.author}
|
||||||
|
</span>
|
||||||
|
{formatTimestamp(comment.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "4px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg-secondary, #1a1a2e)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid var(--border, #333)",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comment.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ opacity: 0.5, marginBottom: "16px" }}>(no steering comments yet)</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
<textarea
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Add a steering comment... (Ctrl+Enter to submit)"
|
||||||
|
maxLength={MAX_LENGTH}
|
||||||
|
rows={4}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
background: "var(--bg-secondary, #1a1a2e)",
|
||||||
|
border: "1px solid var(--border, #333)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "inherit",
|
||||||
|
resize: "vertical",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
opacity: newComment.length > MAX_LENGTH ? 0.9 : 0.5,
|
||||||
|
color:
|
||||||
|
newComment.length > MAX_LENGTH
|
||||||
|
? "var(--error, #ef4444)"
|
||||||
|
: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{newComment.length} / {MAX_LENGTH}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Adding…" : "Add Steering Comment"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask
|
|||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||||
import { AgentLogViewer } from "./AgentLogViewer";
|
import { AgentLogViewer } from "./AgentLogViewer";
|
||||||
|
import { SteeringTab } from "./SteeringTab";
|
||||||
|
|
||||||
function formatTimestamp(iso: string): string {
|
function formatTimestamp(iso: string): string {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
@@ -54,7 +55,7 @@ export function TaskDetailModal({
|
|||||||
onRetryTask,
|
onRetryTask,
|
||||||
addToast,
|
addToast,
|
||||||
}: TaskDetailModalProps) {
|
}: TaskDetailModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"definition" | "agent-log">("definition");
|
const [activeTab, setActiveTab] = useState<"definition" | "agent-log" | "steering">("definition");
|
||||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||||
@@ -282,11 +283,19 @@ export function TaskDetailModal({
|
|||||||
>
|
>
|
||||||
Agent Log
|
Agent Log
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`detail-tab${activeTab === "steering" ? " detail-tab-active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("steering")}
|
||||||
|
>
|
||||||
|
Steering
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === "agent-log" ? (
|
{activeTab === "agent-log" ? (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
|
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
|
||||||
</div>
|
</div>
|
||||||
|
) : activeTab === "steering" ? (
|
||||||
|
<SteeringTab task={task} addToast={addToast} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
|
|||||||
290
packages/dashboard/app/components/__tests__/SteeringTab.test.tsx
Normal file
290
packages/dashboard/app/components/__tests__/SteeringTab.test.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { SteeringTab } from "../SteeringTab";
|
||||||
|
import type { TaskDetail } from "@kb/core";
|
||||||
|
|
||||||
|
// Mock the API module
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
addSteeringComment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { addSteeringComment } from "../../api";
|
||||||
|
|
||||||
|
const mockAddToast = vi.fn();
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||||
|
return {
|
||||||
|
id: "KB-001",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: "2024-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
prompt: "# Test\n\nTest prompt",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SteeringTab", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty state when no comments", () => {
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Steering Comments")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/no steering comments yet/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders comments in reverse chronological order", () => {
|
||||||
|
const task = makeTask({
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
text: "First comment",
|
||||||
|
createdAt: "2024-01-01T00:00:00Z",
|
||||||
|
author: "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
text: "Second comment",
|
||||||
|
createdAt: "2024-01-02T00:00:00Z",
|
||||||
|
author: "agent",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SteeringTab task={task} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
// Comments should be in reverse order (newest first)
|
||||||
|
const comments = screen.getAllByText(/comment$/);
|
||||||
|
expect(comments.length).toBe(2);
|
||||||
|
expect(comments[0].textContent).toBe("Second comment");
|
||||||
|
expect(comments[1].textContent).toBe("First comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows author badges for comments", () => {
|
||||||
|
const task = makeTask({
|
||||||
|
steeringComments: [
|
||||||
|
{ id: "1", text: "User comment", createdAt: "2024-01-01T00:00:00Z", author: "user" },
|
||||||
|
{ id: "2", text: "Agent comment", createdAt: "2024-01-02T00:00:00Z", author: "agent" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SteeringTab task={task} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("user")).toBeTruthy();
|
||||||
|
expect(screen.getByText("agent")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows character count", () => {
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Hello" } });
|
||||||
|
|
||||||
|
expect(screen.getByText("5 / 2000")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables submit button when textarea is empty", () => {
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||||
|
expect(button.hasAttribute("disabled")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables submit button when text exceeds 2000 characters", () => {
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||||
|
const longText = "a".repeat(2001);
|
||||||
|
fireEvent.change(textarea, { target: { value: longText } });
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||||
|
expect(button.hasAttribute("disabled")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables submit button when text is valid", () => {
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Valid comment" } });
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||||
|
expect(button.hasAttribute("disabled")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits comment on button click", async () => {
|
||||||
|
const mockApi = vi.mocked(addSteeringComment);
|
||||||
|
mockApi.mockResolvedValue({
|
||||||
|
...makeTask(),
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "new-1",
|
||||||
|
text: "New comment",
|
||||||
|
createdAt: "2024-01-03T00:00:00Z",
|
||||||
|
author: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||||
|
fireEvent.change(textarea, { target: { value: "New comment" } });
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApi).toHaveBeenCalledWith("KB-001", "New comment");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits comment on Ctrl+Enter", async () => {
|
||||||
|
const mockApi = vi.mocked(addSteeringComment);
|
||||||
|
mockApi.mockResolvedValue({
|
||||||
|
...makeTask(),
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "new-1",
|
||||||
|
text: "Keyboard comment",
|
||||||
|
createdAt: "2024-01-03T00:00:00Z",
|
||||||
|
author: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Keyboard comment" } });
|
||||||
|
|
||||||
|
// Ctrl+Enter should submit
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApi).toHaveBeenCalledWith("KB-001", "Keyboard comment");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits comment on Cmd+Enter (Mac)", async () => {
|
||||||
|
const mockApi = vi.mocked(addSteeringComment);
|
||||||
|
mockApi.mockResolvedValue({
|
||||||
|
...makeTask(),
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "new-1",
|
||||||
|
text: "Mac keyboard comment",
|
||||||
|
createdAt: "2024-01-03T00:00:00Z",
|
||||||
|
author: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Mac keyboard comment" } });
|
||||||
|
|
||||||
|
// Cmd+Enter should submit (metaKey is Cmd on Mac)
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter", metaKey: true });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApi).toHaveBeenCalledWith("KB-001", "Mac keyboard comment");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears textarea after successful submission", async () => {
|
||||||
|
const mockApi = vi.mocked(addSteeringComment);
|
||||||
|
mockApi.mockResolvedValue({
|
||||||
|
...makeTask(),
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "new-1",
|
||||||
|
text: "Cleared comment",
|
||||||
|
createdAt: "2024-01-03T00:00:00Z",
|
||||||
|
author: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(textarea, { target: { value: "Cleared comment" } });
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(textarea.value).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state during submission", async () => {
|
||||||
|
const mockApi = vi.mocked(addSteeringComment);
|
||||||
|
// Delay the resolution to see loading state
|
||||||
|
mockApi.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||||
|
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Loading test" } });
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Should show loading text
|
||||||
|
expect(screen.getByRole("button", { name: /Adding…/ })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast on API failure", async () => {
|
||||||
|
const mockApi = vi.mocked(addSteeringComment);
|
||||||
|
mockApi.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Error test" } });
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAddToast).toHaveBeenCalledWith("Network error", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates comment list after successful submission", async () => {
|
||||||
|
const mockApi = vi.mocked(addSteeringComment);
|
||||||
|
mockApi.mockResolvedValue({
|
||||||
|
...makeTask(),
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "new-1",
|
||||||
|
text: "Added comment",
|
||||||
|
createdAt: "2024-01-03T00:00:00Z",
|
||||||
|
author: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Added comment" } });
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Added comment")).toBeTruthy();
|
||||||
|
expect(screen.getByText("user")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -646,6 +646,47 @@ describe("TaskDetailModal", () => {
|
|||||||
const afterSwitch = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
const afterSwitch = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
||||||
expect(afterSwitch[1]).toBe(true);
|
expect(afterSwitch[1]).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switches to Steering tab", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click Steering tab
|
||||||
|
fireEvent.click(screen.getByText("Steering"));
|
||||||
|
|
||||||
|
// Steering content should appear
|
||||||
|
expect(screen.getByText("Steering Comments")).toBeTruthy();
|
||||||
|
expect(screen.getByPlaceholderText(/Add a steering comment/)).toBeTruthy();
|
||||||
|
// Definition content should be hidden
|
||||||
|
expect(container.querySelector(".markdown-body")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Steering tab as third tab", async () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask()}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabs = screen.getAllByRole("button").filter((b) =>
|
||||||
|
["Definition", "Agent Log", "Steering"].includes(b.textContent || "")
|
||||||
|
);
|
||||||
|
expect(tabs.length).toBe(3);
|
||||||
|
expect(tabs[2].textContent).toBe("Steering");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mobile responsive structure", () => {
|
describe("mobile responsive structure", () => {
|
||||||
@@ -714,11 +755,12 @@ describe("TaskDetailModal", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tabs = container.querySelectorAll(".detail-tab");
|
const tabs = container.querySelectorAll(".detail-tab");
|
||||||
expect(tabs.length).toBe(2);
|
expect(tabs.length).toBe(3);
|
||||||
// Tabs should use class-based styling, not inline styles
|
// 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")).toBe(true);
|
||||||
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active
|
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active
|
||||||
expect(tabs[1].classList.contains("detail-tab-active")).toBe(false);
|
expect(tabs[1].classList.contains("detail-tab-active")).toBe(false);
|
||||||
|
expect(tabs[2].classList.contains("detail-tab-active")).toBe(false);
|
||||||
// Verify no inline padding/fontSize (responsive CSS controls this)
|
// Verify no inline padding/fontSize (responsive CSS controls this)
|
||||||
expect((tabs[0] as HTMLElement).style.padding).toBe("");
|
expect((tabs[0] as HTMLElement).style.padding).toBe("");
|
||||||
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
|
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
updateSettings: vi.fn(),
|
updateSettings: vi.fn(),
|
||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||||
|
addSteeringComment: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
} as unknown as TaskStore;
|
} as unknown as TaskStore;
|
||||||
}
|
}
|
||||||
@@ -655,6 +656,109 @@ describe("Pause/Unpause endpoints", () => {
|
|||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.error).toBe("not found");
|
expect(res.body.error).toBe("not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /tasks/:id/steer", () => {
|
||||||
|
it("adds a steering comment to a task", async () => {
|
||||||
|
const mockComment = {
|
||||||
|
id: "KB-001",
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "1234567890-abc123",
|
||||||
|
text: "Please handle the edge case",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
author: "user" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-001/steer",
|
||||||
|
JSON.stringify({ text: "Please handle the edge case" }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual(mockComment);
|
||||||
|
expect(store.addSteeringComment).toHaveBeenCalledWith(
|
||||||
|
"KB-001",
|
||||||
|
"Please handle the edge case",
|
||||||
|
"user"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when text is missing", async () => {
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/steer", JSON.stringify({}), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("text is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when text is empty", async () => {
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-001/steer",
|
||||||
|
JSON.stringify({ text: "" }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
// Empty string fails the "!text" check, not the length check
|
||||||
|
expect(res.body.error).toContain("text is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when text exceeds 2000 characters", async () => {
|
||||||
|
const longText = "a".repeat(2001);
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-001/steer",
|
||||||
|
JSON.stringify({ text: longText }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("text must be between 1 and 2000 characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when task not found", async () => {
|
||||||
|
const error = new Error("Task not found") as Error & { code?: string };
|
||||||
|
error.code = "ENOENT";
|
||||||
|
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-001/steer",
|
||||||
|
JSON.stringify({ text: "Valid comment" }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 on unexpected errors", async () => {
|
||||||
|
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("Database error")
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-001/steer",
|
||||||
|
JSON.stringify({ text: "Valid comment" }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toBe("Database error");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- GitHub Import route tests ---
|
// --- GitHub Import route tests ---
|
||||||
|
|||||||
@@ -259,6 +259,26 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add steering comment to task
|
||||||
|
router.post("/tasks/:id/steer", 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.addSteeringComment(req.params.id, text, "user");
|
||||||
|
res.json(task);
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.code === "ENOENT" ? 404 : 500;
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update task
|
// Update task
|
||||||
router.patch("/tasks/:id", async (req, res) => {
|
router.patch("/tasks/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -994,6 +994,83 @@ describe("buildExecutionPrompt", () => {
|
|||||||
expect(result).not.toContain("## Project Commands");
|
expect(result).not.toContain("## Project Commands");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes Steering Comments section when steeringComments has entries", () => {
|
||||||
|
const task = createMockTaskDetail({
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
text: "Please handle the edge case",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
author: "user" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = buildExecutionPrompt(task);
|
||||||
|
|
||||||
|
expect(result).toContain("## Steering Comments");
|
||||||
|
expect(result).toContain("**user**");
|
||||||
|
expect(result).toContain("> Please handle the edge case");
|
||||||
|
expect(result).toContain("The following steering comments were added by the user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats multiple steering comments correctly", () => {
|
||||||
|
const now = new Date();
|
||||||
|
const task = createMockTaskDetail({
|
||||||
|
steeringComments: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
text: "First comment",
|
||||||
|
createdAt: new Date(now.getTime() - 60000).toISOString(), // 1 minute ago
|
||||||
|
author: "user" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
text: "Second comment",
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
author: "agent" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = buildExecutionPrompt(task);
|
||||||
|
|
||||||
|
expect(result).toContain("**user**");
|
||||||
|
expect(result).toContain("**agent**");
|
||||||
|
expect(result).toContain("> First comment");
|
||||||
|
expect(result).toContain("> Second comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Steering Comments section when steeringComments is empty", () => {
|
||||||
|
const task = createMockTaskDetail({ steeringComments: [] });
|
||||||
|
const result = buildExecutionPrompt(task);
|
||||||
|
|
||||||
|
expect(result).not.toContain("## Steering Comments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Steering Comments section when steeringComments is undefined", () => {
|
||||||
|
const task = createMockTaskDetail();
|
||||||
|
const result = buildExecutionPrompt(task);
|
||||||
|
|
||||||
|
expect(result).not.toContain("## Steering Comments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes only the 10 most recent steering comments", () => {
|
||||||
|
const steeringComments = Array.from({ length: 15 }, (_, i) => ({
|
||||||
|
id: `${i}`,
|
||||||
|
text: `Comment ${i}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
author: "user" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const task = createMockTaskDetail({ steeringComments });
|
||||||
|
const result = buildExecutionPrompt(task);
|
||||||
|
|
||||||
|
// Should include comments 5-14 (the 10 most recent), not 0-4
|
||||||
|
expect(result).toContain("> Comment 5");
|
||||||
|
expect(result).toContain("> Comment 14");
|
||||||
|
expect(result).not.toContain("> Comment 0");
|
||||||
|
expect(result).not.toContain("> Comment 4");
|
||||||
|
});
|
||||||
|
|
||||||
it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => {
|
it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
|
|||||||
@@ -959,6 +959,25 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a timestamp for display in steering comments.
|
||||||
|
* Returns relative time for recent comments, absolute date for older ones.
|
||||||
|
*/
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
|
||||||
|
if (diffMin < 1) return "just now";
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
// Project commands are injected here (for reliability) and also in the PROMPT.md (by triage).
|
// Project commands are injected here (for reliability) and also in the PROMPT.md (by triage).
|
||||||
// This ensures the executor agent always sees the authoritative commands from settings,
|
// This ensures the executor agent always sees the authoritative commands from settings,
|
||||||
// even if the PROMPT.md was written manually or before commands were configured.
|
// even if the PROMPT.md was written manually or before commands were configured.
|
||||||
@@ -1019,6 +1038,26 @@ git log --oneline
|
|||||||
commandsSection = "\n" + lines.join("\n") + "\n";
|
commandsSection = "\n" + lines.join("\n") + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build steering comments section (last 10 comments only to avoid context bloat)
|
||||||
|
let steeringSection = "";
|
||||||
|
if (task.steeringComments && task.steeringComments.length > 0) {
|
||||||
|
const recentComments = [...task.steeringComments].slice(-10);
|
||||||
|
const lines = [
|
||||||
|
"",
|
||||||
|
"## Steering Comments",
|
||||||
|
"",
|
||||||
|
"The following steering comments were added by the user during execution. Consider adjusting your approach or replanning remaining steps based on this feedback.",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
for (const comment of recentComments) {
|
||||||
|
const timestamp = formatTimestamp(comment.createdAt);
|
||||||
|
lines.push(`**${comment.author}** — ${timestamp}`);
|
||||||
|
lines.push(`> ${comment.text}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
steeringSection = lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
return `Execute this task.
|
return `Execute this task.
|
||||||
|
|
||||||
## Task: ${task.id}
|
## Task: ${task.id}
|
||||||
@@ -1028,7 +1067,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
|
|||||||
## PROMPT.md
|
## PROMPT.md
|
||||||
|
|
||||||
${task.prompt}
|
${task.prompt}
|
||||||
${attachmentsSection}${commandsSection}${progressSection}
|
${attachmentsSection}${commandsSection}${progressSection}${steeringSection}
|
||||||
## Review level: ${reviewLevel}
|
## Review level: ${reviewLevel}
|
||||||
|
|
||||||
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}
|
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}
|
||||||
|
|||||||
Reference in New Issue
Block a user