feat(FN-4829): merge fusion/fn-4829

This commit is contained in:
gsxdsm
2026-05-16 22:26:42 -07:00
parent 8ae7f3d97f
commit df998cb54f
20 changed files with 1085 additions and 33 deletions

View File

@@ -291,13 +291,47 @@ export interface ReviseTaskReviewResponse {
reviewState: NonNullable<TaskDetail["reviewState"]>;
}
export interface DuplicateMatch {
id: string;
title: string;
description: string;
column: string;
score: number;
}
export class DuplicateCandidatesError extends Error {
readonly matches: DuplicateMatch[];
constructor(matches: DuplicateMatch[]) {
super("duplicate_candidates");
this.name = "DuplicateCandidatesError";
this.matches = matches;
}
}
export interface CreateTaskRequestOptions {
transportNodeId?: string;
localNodeId?: string;
}
export function createTask(
input: TaskCreateInput,
export type CreateTaskInput = TaskCreateInput & {
acknowledgedDuplicates?: string[];
bypassDuplicateCheck?: boolean;
};
export async function checkDuplicateTasks(
input: { title?: string; description: string },
projectId?: string,
): Promise<DuplicateMatch[]> {
const response = await api<{ matches?: DuplicateMatch[] }>(withProjectId("/tasks/duplicate-check", projectId), {
method: "POST",
body: JSON.stringify(input),
});
return response.matches ?? [];
}
export async function createTask(
input: CreateTaskInput,
projectId?: string,
options?: CreateTaskRequestOptions,
): Promise<Task> {
@@ -326,9 +360,12 @@ export function createTask(
branch,
baseBranch,
githubTracking,
acknowledgedDuplicates,
bypassDuplicateCheck,
} = input;
return proxyApi<Task>(withProjectId("/tasks", projectId), {
try {
return await proxyApi<Task>(withProjectId("/tasks", projectId), {
method: "POST",
nodeId: options?.transportNodeId,
localNodeId: options?.localNodeId,
@@ -357,8 +394,19 @@ export function createTask(
branch,
baseBranch,
githubTracking,
acknowledgedDuplicates,
bypassDuplicateCheck,
}),
});
} catch (error) {
if (error instanceof ApiRequestError && error.status === 409 && error.message === "duplicate_candidates") {
const matches = Array.isArray(error.details?.matches)
? (error.details?.matches as DuplicateMatch[])
: [];
throw new DuplicateCandidatesError(matches);
}
throw error;
}
}
export function updateTask(
@@ -5665,6 +5713,7 @@ export interface ActivityFeedEntry {
| "task:deleted"
| "task:merged"
| "task:failed"
| "task:duplicate-warning-overridden"
| "settings:updated"
| "project:isolation-transition";
projectId: string;

View File

@@ -31,6 +31,7 @@ const TYPE_CONFIG: Record<ActivityFeedEntry["type"], {
"task:deleted": { label: "Deleted", icon: XCircle, color: "var(--color-error)" },
"task:merged": { label: "Merged", icon: GitMerge, color: "var(--color-success)" },
"task:failed": { label: "Failed", icon: AlertTriangle, color: "var(--color-error)" },
"task:duplicate-warning-overridden": { label: "Duplicate Override", icon: AlertTriangle, color: "var(--color-warning)" },
"settings:updated": { label: "Settings", icon: Settings, color: "var(--text-muted)" },
"project:isolation-transition": { label: "Isolation", icon: Folder, color: "var(--color-info)" },
};

View File

@@ -30,6 +30,7 @@ const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
"task:deleted": "Task Deleted",
"task:merged": "Task Merged",
"task:failed": "Task Failed",
"task:duplicate-warning-overridden": "Duplicate Warning Overridden",
"settings:updated": "Settings Updated",
"project:isolation-transition": "Project Isolation Transition",
};
@@ -41,6 +42,7 @@ const EVENT_TYPE_ICONS: Record<ActivityEventType, React.ReactNode> = {
"task:deleted": <X size={14} className="activity-icon deleted" />,
"task:merged": <CheckCircle size={14} className="activity-icon merged" />,
"task:failed": <XCircle size={14} className="activity-icon failed" />,
"task:duplicate-warning-overridden": <AlertCircle size={14} className="activity-icon updated" />,
"settings:updated": <Settings size={14} className="activity-icon settings" />,
"project:isolation-transition": <Folder size={14} className="activity-icon settings" />,
};

View File

@@ -468,6 +468,16 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
onOpenTask={(taskId) => {
const matchingTask = (allTasks ?? []).find((candidate) => candidate.id === taskId);
if (matchingTask) {
onOpenDetail(matchingTask);
return;
}
if (typeof window !== "undefined") {
window.location.hash = `#/tasks/${taskId}`;
}
}}
/>
)}
{column === "in-progress" ? (

View File

@@ -0,0 +1,62 @@
.duplicate-warning-modal {
max-width: min(100%, calc(var(--space-2xl) * 18));
}
.duplicate-warning-modal-body {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: 0 var(--space-lg) var(--space-md);
}
.duplicate-warning-modal-copy {
margin: 0;
color: var(--text-muted);
}
.duplicate-warning-modal-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
max-height: calc(var(--space-2xl) * 8);
overflow-y: auto;
}
.duplicate-warning-modal-item {
cursor: default;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.duplicate-warning-modal-item-header {
align-items: center;
display: flex;
gap: var(--space-sm);
}
.duplicate-warning-modal-score {
margin-left: auto;
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-pill);
color: var(--text-muted);
font-size: 0.75rem;
padding: var(--space-xs) var(--space-sm);
}
.duplicate-warning-modal-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.duplicate-warning-modal-actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.duplicate-warning-modal {
width: 100%;
}
}

View File

@@ -0,0 +1,69 @@
import "./DuplicateWarningModal.css";
import { useEffect, useRef } from "react";
import type { DuplicateMatch } from "../api";
interface DuplicateWarningModalProps {
matches: DuplicateMatch[];
onOpen: (id: string) => void;
onProceed: () => void;
onCancel: () => void;
}
function toStatusClass(column: string): string {
return `card-status-badge--${column}`;
}
export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }: DuplicateWarningModalProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onCancel]);
return (
<div className="modal-overlay open" role="presentation">
<div className="modal duplicate-warning-modal" role="dialog" aria-modal="true" aria-labelledby="duplicate-warning-modal-title">
<div className="modal-header">
<h3 id="duplicate-warning-modal-title">Possible duplicates</h3>
</div>
<div className="duplicate-warning-modal-body">
<p className="duplicate-warning-modal-copy">We found similar active tasks. Open an existing task or create this one anyway.</p>
<div className="duplicate-warning-modal-list">
{matches.map((match) => (
<article className="card duplicate-warning-modal-item" key={match.id}>
<div className="duplicate-warning-modal-item-header">
<span className="card-id">{match.id}</span>
<span className={`card-status-badge ${toStatusClass(match.column)}`}>{match.column}</span>
<span className="duplicate-warning-modal-score">{Math.round(match.score * 100)}%</span>
</div>
<div className="card-title duplicate-warning-modal-title">{match.title || "Untitled task"}</div>
<div className="duplicate-warning-modal-actions">
<button className="btn btn-sm" type="button" onClick={() => onOpen(match.id)}>Open</button>
</div>
</article>
))}
</div>
</div>
<div className="modal-actions">
<div className="modal-actions-left">
<button className="btn" type="button" ref={cancelButtonRef} onClick={onCancel}>Cancel</button>
</div>
<div className="modal-actions-right">
<button className="btn btn-primary" type="button" onClick={onProceed}>Create anyway</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1514,6 +1514,16 @@ export function ListView({
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
onOpenTask={(taskId) => {
const matchingTask = tasks.find((candidate) => candidate.id === taskId);
if (matchingTask) {
onOpenDetail(matchingTask);
return;
}
if (typeof window !== "undefined") {
window.location.hash = `#/tasks/${taskId}`;
}
}}
/>
</div>
{filteredCount === 0 ? (

View File

@@ -3,9 +3,10 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import type { ToastType } from "../hooks/useToast";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
import type { Task, TaskCreateInput, Settings, TaskPriority } from "@fusion/core";
import type { ModelInfo, RefinementType, Agent } from "../api";
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
import type { Task, Settings, TaskPriority } from "@fusion/core";
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch } from "../api";
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
@@ -23,7 +24,7 @@ interface PendingImage {
}
interface QuickEntryBoxProps {
onCreate?: (input: TaskCreateInput) => Promise<Task | void>;
onCreate?: (input: CreateTaskInput) => Promise<Task | void>;
addToast: (message: string, type?: ToastType) => void;
tasks?: Task[];
availableModels?: ModelInfo[];
@@ -63,6 +64,7 @@ interface QuickEntryBoxProps {
* Toggle favorite model callback from shared app-level state.
*/
onToggleModelFavorite?: (modelId: string) => void;
onOpenTask?: (id: string) => void;
}
function getNodeStatusLabel(status: NodeInfo["status"]): string {
@@ -92,7 +94,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
};
}
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite }: QuickEntryBoxProps) {
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
const [description, setDescription] = useState(() => {
if (typeof window !== "undefined") {
return getScopedItem(STORAGE_KEY, projectId) || "";
@@ -161,8 +163,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [githubTrackingOverride, setGithubTrackingOverride] = useState<boolean | null>(null);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
const { nodes } = useNodes();
// AI Refinement state
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
const [isRefining, setIsRefining] = useState(false);
@@ -489,13 +491,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
});
}, []);
const handleSubmit = useCallback(async () => {
const trimmed = description.trim();
if (!trimmed || isSubmitting || !onCreate) return;
const submitCreateTask = useCallback(async (trimmed: string, overrides?: { acknowledgedDuplicates?: string[] }) => {
if (!onCreate) {
return;
}
const originalDescription = description;
setIsSubmitting(true);
// Optimistically clear text for rapid entry; restore on failure.
setDescription("");
try {
const createdTask = await onCreate({
@@ -516,6 +518,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
: undefined,
priority,
nodeId,
acknowledgedDuplicates: overrides?.acknowledgedDuplicates,
});
if (createdTask && pendingImages.length > 0) {
const failures: string[] = [];
@@ -531,22 +534,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
}
}
// Clear input for rapid entry
resetForm();
// Note: Focus restoration is handled by useEffect when isSubmitting becomes false
} catch (err) {
setDescription(originalDescription);
addToast(getErrorMessage(err) || "Failed to create task", "error");
// Keep input content on failure so user can retry
} finally {
setIsSubmitting(false);
}
}, [
description,
isSubmitting,
onCreate,
description,
dependencies,
selectedAgentId,
selectedPresetId,
hasExecutorOverride,
executorProvider,
executorModelId,
@@ -556,17 +556,58 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
hasPlanningOverride,
planningProvider,
planningModelId,
isFastMode,
settings,
githubTrackingOverride,
priority,
nodeId,
pendingImages,
projectId,
addToast,
resetForm,
isFastMode,
githubTrackingOverride,
settings,
priority,
nodeId,
]);
const handleSubmit = useCallback(async () => {
const trimmed = description.trim();
if (!trimmed || isSubmitting || !onCreate) return;
try {
const matches = await checkDuplicateTasks({ description: trimmed }, projectId);
if (matches.length > 0) {
setDuplicateMatches(matches);
return;
}
} catch (_error) {
addToast("Duplicate check failed; creating task anyway.", "error");
}
await submitCreateTask(trimmed);
}, [description, isSubmitting, onCreate, projectId, submitCreateTask, addToast]);
const handleDuplicateOpen = useCallback((taskId: string) => {
if (onOpenTask) {
onOpenTask(taskId);
} else if (typeof window !== "undefined") {
window.location.hash = `#/tasks/${taskId}`;
}
setDuplicateMatches(null);
}, [onOpenTask]);
const handleDuplicateProceed = useCallback(async () => {
const trimmed = description.trim();
const matches = duplicateMatches;
if (!trimmed || !matches || matches.length === 0) {
setDuplicateMatches(null);
return;
}
setDuplicateMatches(null);
await submitCreateTask(trimmed, { acknowledgedDuplicates: matches.map((match) => match.id) });
}, [description, duplicateMatches, submitCreateTask]);
const handleDuplicateCancel = useCallback(() => {
setDuplicateMatches(null);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter") {
@@ -1357,7 +1398,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}, []);
return (
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
<>
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
<div className="description-with-refine">
<div className="quick-entry-main-row">
<div className="quick-entry-textarea-wrap">
@@ -2045,5 +2087,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
Enter to create · Esc to cancel
</div>
</div>
{duplicateMatches && (
<DuplicateWarningModal
matches={duplicateMatches}
onOpen={handleDuplicateOpen}
onProceed={handleDuplicateProceed}
onCancel={handleDuplicateCancel}
/>
)}
</>
);
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { DuplicateWarningModal } from "../DuplicateWarningModal";
import type { DuplicateMatch } from "../../api";
const matches: DuplicateMatch[] = [
{ id: "FN-101", title: "Fix duplicate task flow", description: "...", column: "todo", score: 0.81 },
{ id: "FN-102", title: "Another duplicate", description: "...", column: "in-progress", score: 0.67 },
];
describe("DuplicateWarningModal", () => {
it("renders one row per match with id and title", () => {
render(<DuplicateWarningModal matches={matches} onOpen={vi.fn()} onProceed={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByText("FN-101")).toBeInTheDocument();
expect(screen.getByText("FN-102")).toBeInTheDocument();
expect(screen.getByText("Fix duplicate task flow")).toBeInTheDocument();
expect(screen.getByText("Another duplicate")).toBeInTheDocument();
});
it("calls onOpen with the selected id", () => {
const onOpen = vi.fn();
render(<DuplicateWarningModal matches={matches} onOpen={onOpen} onProceed={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getAllByRole("button", { name: "Open" })[1]);
expect(onOpen).toHaveBeenCalledWith("FN-102");
});
it("calls onProceed", () => {
const onProceed = vi.fn();
render(<DuplicateWarningModal matches={matches} onOpen={vi.fn()} onProceed={onProceed} onCancel={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Create anyway" }));
expect(onProceed).toHaveBeenCalledTimes(1);
});
it("calls onCancel for cancel click and Escape", () => {
const onCancel = vi.fn();
render(<DuplicateWarningModal matches={matches} onOpen={vi.fn()} onProceed={vi.fn()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
fireEvent.keyDown(document, { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(2);
});
});

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { QuickEntryBox } from "../QuickEntryBox";
import type { Task } from "@fusion/core";
import { fetchSettings, fetchAgents, uploadAttachment } from "../../api";
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment } from "../../api";
import { useNodes } from "../../hooks/useNodes";
import { scopedKey } from "../../utils/projectStorage";
@@ -98,6 +98,7 @@ vi.mock("../../api", () => ({
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
fetchAgents: vi.fn().mockResolvedValue([]),
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
uploadAttachment: vi.fn().mockResolvedValue({}),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
}));
@@ -256,6 +257,7 @@ describe("QuickEntryBox", () => {
healthCheck: vi.fn(),
});
vi.mocked(uploadAttachment).mockResolvedValue({} as any);
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
@@ -3334,4 +3336,66 @@ describe("QuickEntryBox", () => {
expect(screen.getByTestId("quick-entry-node-button")).toHaveTextContent("Node Two");
});
describe("FN-4829 duplicate detection", () => {
it("opens duplicate warning modal and does not create immediately", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-123", title: "Duplicate", description: "desc", column: "todo", score: 0.9 },
]);
renderQuickEntryBox({ onCreate });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "duplicate candidate" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(await screen.findByText("Possible duplicates")).toBeInTheDocument();
expect(onCreate).not.toHaveBeenCalled();
});
it("creates immediately when duplicate check has no matches", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([]);
renderQuickEntryBox({ onCreate });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "fresh task" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
expect(onCreate.mock.calls[0]?.[0]).toHaveProperty("acknowledgedDuplicates", undefined);
});
it("sends acknowledgedDuplicates when creating anyway", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-456", title: "Duplicate", description: "desc", column: "todo", score: 0.7 },
]);
renderQuickEntryBox({ onCreate });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "maybe duplicate" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.click(await screen.findByRole("button", { name: "Create anyway" }));
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ acknowledgedDuplicates: ["FN-456"] }));
});
});
it("continues creation when duplicate check fails and shows toast", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
const addToast = vi.fn();
vi.mocked(checkDuplicateTasks).mockRejectedValueOnce(new Error("boom"));
renderQuickEntryBox({ onCreate, addToast });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "task despite failure" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
expect(addToast).toHaveBeenCalledWith("Duplicate check failed; creating task anyway.", "error");
});
});
});

View File

@@ -0,0 +1,224 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import type { Column, Task, TaskStore } from "@fusion/core";
import { request as performRequest } from "../test-request.js";
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
import { ApiError, sendErrorResponse } from "../api-error.js";
function createTaskFixture(overrides: Partial<Task> & { id: string; description: string; column: Column }): Task {
const now = new Date().toISOString();
return {
id: overrides.id,
description: overrides.description,
column: overrides.column,
dependencies: [],
createdAt: now,
updatedAt: now,
size: "M",
subtasks: [],
log: [],
tags: [],
blockedBy: [],
source: { sourceType: "api" },
...overrides,
} as Task;
}
function buildApp(seed: Task[] = []) {
const tasks = [...seed];
const recordActivity = vi.fn().mockResolvedValue(undefined);
const store: Partial<TaskStore> = {
searchTasks: vi.fn().mockImplementation(async () => tasks),
getSettingsFast: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
createTask: vi.fn().mockImplementation(async (input: { title?: string; description: string; source?: Record<string, unknown> }) => {
const task = createTaskFixture({
id: `FN-${tasks.length + 100}`,
title: input.title,
description: input.description,
column: "todo",
source: (input.source as Task["source"]) ?? { sourceType: "api" },
});
tasks.push(task);
return task;
}),
recordActivity,
};
const router = express.Router();
registerTaskWorkflowRoutes(
{
router,
store: store as TaskStore,
options: {},
runtimeLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
planningLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
chatLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
getProjectIdFromRequest: () => undefined,
getScopedStore: async () => store as TaskStore,
getProjectContext: async () => ({ store: store as TaskStore, engine: undefined, projectId: undefined }),
prioritizeProjectsForCurrentDirectory: (projects) => projects,
emitRemoteRouteDiagnostic: () => {},
emitAuthSyncAuditLog: () => {},
parseScopeParam: () => undefined,
resolveAutomationStore: () => ({}) as never,
resolveRoutineStore: () => ({}) as never,
resolveRoutineRunner: () => ({}) as never,
registerDispose: () => {},
dispose: () => {},
rethrowAsApiError: (error: unknown): never => {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError(500, error instanceof Error ? error.message : "Internal server error");
},
},
{
runtimeLogger: { error: vi.fn(), warn: vi.fn() },
upload: { single: () => (_req: unknown, _res: unknown, next: () => void) => next() },
taskDetailActivityLogLimit: 100,
validateOptionalModelField: (value) => (typeof value === "string" ? value : undefined),
normalizeModelSelectionPair: (provider, modelId) => ({ provider: provider ?? null, modelId: modelId ?? null }),
runGitCommand: async () => "",
trimTaskDetailActivityLog: (task) => task,
triggerCommentWakeForAssignedAgent: async () => {},
},
);
const app = express();
app.use(express.json());
app.use("/api", router);
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (error instanceof ApiError) {
sendErrorResponse(res, error.statusCode, error.message, { details: error.details });
return;
}
sendErrorResponse(res, 500, error instanceof Error ? error.message : "Internal server error");
});
return { app, store, recordActivity };
}
describe("task duplicate detection routes", () => {
it("POST /tasks/duplicate-check returns matches for high similarity", async () => {
const { app } = buildApp([
createTaskFixture({
id: "FN-10",
title: "Add duplicate task warning",
description: "Warn before creating duplicate tasks from quick entry",
column: "todo",
}),
]);
const res = await performRequest(
app,
"POST",
"/api/tasks/duplicate-check",
JSON.stringify({ description: "Warn before creating duplicate tasks from quick entry" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
const body = res.body as { matches: Array<{ id: string }> };
expect(body.matches.map((match) => match.id)).toContain("FN-10");
});
it("returns empty matches for unrelated descriptions", async () => {
const { app } = buildApp([
createTaskFixture({ id: "FN-11", title: "Retry scheduler", description: "Adjust retry windows", column: "todo" }),
]);
const res = await performRequest(
app,
"POST",
"/api/tasks/duplicate-check",
JSON.stringify({ description: "Completely unrelated modal styling issue" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect((res.body as { matches: unknown[] }).matches).toEqual([]);
});
it("POST /tasks returns 409 when duplicate exists without acknowledgement", async () => {
const { app } = buildApp([
createTaskFixture({ id: "FN-12", title: "Duplicate warning", description: "Warn before task creation", column: "todo" }),
]);
const res = await performRequest(
app,
"POST",
"/api/tasks",
JSON.stringify({ description: "Warn before task creation" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(409);
const body = res.body as { error: string; details: { matches: Array<{ id: string }> } };
expect(body.error).toBe("duplicate_candidates");
expect(body.details.matches.map((match) => match.id)).toContain("FN-12");
});
it("creates task with override metadata and records activity", async () => {
const { app, store, recordActivity } = buildApp([
createTaskFixture({ id: "FN-13", title: "Duplicate warning", description: "Warn before task creation", column: "todo" }),
]);
const res = await performRequest(
app,
"POST",
"/api/tasks",
JSON.stringify({ description: "Warn before task creation", acknowledgedDuplicates: ["FN-13"] }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(201);
const created = res.body as Task;
expect(created.source?.sourceMetadata?.duplicateWarningOverridden).toBe(true);
expect(created.source?.sourceMetadata?.acknowledgedDuplicateIds).toEqual(["FN-13"]);
expect(recordActivity).toHaveBeenCalledWith(
expect.objectContaining({
type: "task:duplicate-warning-overridden",
metadata: expect.objectContaining({ acknowledgedDuplicateIds: ["FN-13"] }),
}),
);
expect((store.createTask as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
});
it("bypassDuplicateCheck creates task without override metadata", async () => {
const { app } = buildApp([
createTaskFixture({ id: "FN-14", title: "Duplicate warning", description: "Warn before task creation", column: "todo" }),
]);
const res = await performRequest(
app,
"POST",
"/api/tasks",
JSON.stringify({ description: "Warn before task creation", bypassDuplicateCheck: true }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(201);
const created = res.body as Task;
expect(created.source?.sourceMetadata?.duplicateWarningOverridden).toBeUndefined();
});
it("done tasks do not trigger conflict", async () => {
const { app } = buildApp([
createTaskFixture({ id: "FN-15", title: "Duplicate warning", description: "Warn before task creation", column: "done" }),
]);
const res = await performRequest(
app,
"POST",
"/api/tasks",
JSON.stringify({ description: "Warn before task creation" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(201);
});
});

View File

@@ -89,8 +89,8 @@ export function notFound(message: string): ApiError {
return new ApiError(404, message);
}
export function conflict(message: string): ApiError {
return new ApiError(409, message);
export function conflict(message: string, details?: Record<string, unknown>): ApiError {
return new ApiError(409, message, details);
}
export function rateLimited(message: string, retryAfter?: number): ApiError {

View File

@@ -1,5 +1,16 @@
import { createReadStream } from "node:fs";
import type { TaskStore, Task, TaskDetail, Column, TaskReviewData, TaskReviewItem, TaskReviewSummary, GithubIssueAction } from "@fusion/core";
import type {
TaskStore,
Task,
TaskDetail,
Column,
TaskReviewData,
TaskReviewItem,
TaskReviewSummary,
GithubIssueAction,
DuplicateCandidate,
DuplicateMatch,
} from "@fusion/core";
import {
COLUMNS,
TASK_PRIORITIES,
@@ -11,6 +22,7 @@ import {
canAgentTakeImplementationTaskForExplicitRouting,
formatRoleMismatchReason,
getCurrentRepo,
findDuplicateMatches,
} from "@fusion/core";
import { GitHubClient } from "../github.js";
import { createTrackingIssueForTask } from "../github-tracking-hook.js";
@@ -23,6 +35,53 @@ import { resolveBranchSelection } from "./branch-selection.js";
const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi;
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
function buildDuplicateQuery(title: string | undefined, description: string): string {
const tokens = `${title ?? ""} ${description}`
.toLowerCase()
.split(/\W+/)
.map((token) => token.trim())
.filter((token) => token.length > 0 && !DUPLICATE_STOPWORDS.has(token));
const deduped = [...new Set(tokens)];
const selected = deduped.sort((left, right) => right.length - left.length).slice(0, 5);
return selected.join(" ");
}
async function computeDuplicateMatches(
scopedStore: TaskStore,
input: { title?: string; description: string; limit?: number; threshold?: number },
): Promise<DuplicateMatch[]> {
const query = buildDuplicateQuery(input.title, input.description);
if (query.length === 0) {
return [];
}
const results = await scopedStore.searchTasks(query, {
slim: true,
includeArchived: false,
limit: 20,
});
const candidates: DuplicateCandidate[] = results.map((task) => ({
id: task.id,
title: task.title ?? "",
description: task.description ?? "",
column: task.column,
}));
return findDuplicateMatches(
{
title: input.title,
description: input.description,
},
candidates,
{
threshold: input.threshold,
limit: input.limit ?? 5,
},
);
}
function buildReviewerAgentItemId(input: { index: number; reviewType: "plan" | "code"; step?: number; verdict?: string; createdAt?: string }): string {
const stepPart = input.step ? `step-${input.step}` : "step-na";
@@ -155,6 +214,38 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
router.post("/tasks/duplicate-check", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { title, description, limit, threshold } = req.body ?? {};
if (typeof description !== "string" || description.trim().length === 0) {
throw badRequest("description is required");
}
if (limit !== undefined && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1)) {
throw badRequest("limit must be a positive integer");
}
if (threshold !== undefined && (typeof threshold !== "number" || Number.isNaN(threshold))) {
throw badRequest("threshold must be a number");
}
const matches = await computeDuplicateMatches(scopedStore, {
title: typeof title === "string" ? title : undefined,
description: description.trim(),
limit,
threshold,
});
res.json({ matches });
return;
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
// Create task
router.post("/tasks", async (req, res) => {
try {
@@ -183,10 +274,22 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
branchSelection,
nodeId,
githubTracking,
acknowledgedDuplicates,
bypassDuplicateCheck,
} = req.body;
if (!description || typeof description !== "string") {
throw badRequest("description is required");
}
if (
acknowledgedDuplicates !== undefined
&& (!Array.isArray(acknowledgedDuplicates)
|| !acknowledgedDuplicates.every((taskId: unknown) => typeof taskId === "string" && /^[A-Z]+-\d+$/.test(taskId)))
) {
throw badRequest("acknowledgedDuplicates must be an array of task IDs");
}
if (bypassDuplicateCheck !== undefined && typeof bypassDuplicateCheck !== "boolean") {
throw badRequest("bypassDuplicateCheck must be a boolean");
}
if (breakIntoSubtasks !== undefined && typeof breakIntoSubtasks !== "boolean") {
throw badRequest("breakIntoSubtasks must be a boolean");
}
@@ -301,9 +404,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
};
}
const normalizedDescription = description.trim();
const normalizedTitle = typeof title === "string" ? title : undefined;
const acknowledgedDuplicateIds = acknowledgedDuplicates ?? [];
const duplicateMatches = bypassDuplicateCheck === true
? []
: await computeDuplicateMatches(scopedStore, {
title: normalizedTitle,
description: normalizedDescription,
});
const matchesAfterAckFilter = duplicateMatches.filter((match) => !acknowledgedDuplicateIds.includes(match.id));
if (matchesAfterAckFilter.length > 0) {
throw conflict("duplicate_candidates", { matches: matchesAfterAckFilter });
}
const createInput = {
title,
description,
title: normalizedTitle,
description: normalizedDescription,
column,
dependencies,
breakIntoSubtasks,
@@ -320,7 +437,17 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
reviewLevel: reviewLevel ?? undefined,
executionMode: executionMode || undefined,
priority: priority ?? undefined,
source: normalizedSource,
source:
acknowledgedDuplicateIds.length > 0
? {
...(normalizedSource as Record<string, unknown>),
sourceMetadata: {
...((normalizedSource as { sourceMetadata?: Record<string, unknown> }).sourceMetadata ?? {}),
duplicateWarningOverridden: true,
acknowledgedDuplicateIds,
},
}
: normalizedSource,
branch: normalizedBranch,
baseBranch: normalizedBaseBranch,
...(typeof nodeId === "string" && nodeId.trim().length > 0 ? { nodeId: nodeId.trim() } : {}),
@@ -332,6 +459,27 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
);
await createTrackingIssueForTask(scopedStore, task, { githubToken: options?.githubToken });
if (acknowledgedDuplicateIds.length > 0) {
try {
await scopedStore.recordActivity({
type: "task:duplicate-warning-overridden",
taskId: task.id,
taskTitle: task.title,
details: `Created despite ${acknowledgedDuplicateIds.length} possible duplicate(s): ${acknowledgedDuplicateIds.join(", ")}`,
metadata: {
acknowledgedDuplicateIds,
matches: matchesAfterAckFilter.map((match) => ({ id: match.id, score: match.score })),
},
});
} catch (error) {
runtimeLogger.warn("Failed to record duplicate warning override activity", {
taskId: task.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
res.status(201).json(task);
return;
} catch (err: unknown) {

View File

@@ -11,7 +11,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ReliabilityView,ResearchView,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,TrackingRepoSelect,WorktrunkInstallApprovalDetails,WorkflowResultsTab}.test.tsx",
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ReliabilityView,ResearchView,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,TrackingRepoSelect,WorktrunkInstallApprovalDetails,WorkflowResultsTab}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
@@ -21,7 +21,7 @@ const qualityAppTests = [
const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-task-commit-associations,routes-tasks,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-duplicate-check,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
];