feat(FN-3056): merge fusion/fn-3056
This merge ships several feature and infrastructure improvements across the codebase. Task title validation is strengthened in triage with stricter rejection of malformed titles and preference for prompt-declared titles (FN-3056), while task creation now preserves priority settings (FN-3210). The Mi Fusion-Task-Id: FN-3056
This commit is contained in:
5
.changeset/fn-3056-task-title-sanitization.md
Normal file
5
.changeset/fn-3056-task-title-sanitization.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Prevent malformed task titles derived from assistant/tool confirmation prose (for example, `Created task **FN-1234** ...`) from being persisted as task titles. The triage finalization/recovery flow now also prefers canonical prompt headings (`# Task: FN-XXXX - Title`) when they match the task ID, so approved specs restore the intended human-readable title in metadata.
|
||||
@@ -50,7 +50,7 @@ Expand the creation panel (▼) to access additional controls:
|
||||
- **Deps** (🔗) — Link existing tasks as dependencies
|
||||
- **Attach** — Add image attachments
|
||||
- **Models** (🧠) — Set per-task model overrides (executor, validator, planning)
|
||||
- **Priority** (🚩) — Set task priority (`low`, `normal`, `high`, `urgent`) before creation
|
||||
- **Priority** (🚩) — Set task priority (`low`, `normal`, `high`, `urgent`) before creation; the selected value is applied to the created task (it does not reset to default unless omitted)
|
||||
- **Agent** — Assign an agent to the task
|
||||
- **Review** — Set review rigor level (None, Plan Only, Plan and Code, Full)
|
||||
- **Browser Verify** — Enable browser verification workflow step
|
||||
|
||||
@@ -270,15 +270,13 @@ describe("ai-summarize", () => {
|
||||
expect(sanitizeTitle("\n\n hello world \nignored")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("strips chatty markdown reply (FN-3057 incident shape)", () => {
|
||||
const raw =
|
||||
"Created **FN-3058** with the full spec. Let me know if you want changes.";
|
||||
// First line is the whole thing — sanitizer should strip the markdown bold
|
||||
// and trailing period; truncation happens at MAX_TITLE_LENGTH (60).
|
||||
const out = sanitizeTitle(raw)!;
|
||||
expect(out).not.toContain("**");
|
||||
expect(out.length).toBeLessThanOrEqual(60);
|
||||
expect(out.startsWith("Created FN-3058")).toBe(true);
|
||||
it("rejects task-creation confirmation prose (FN-3056 regression)", () => {
|
||||
expect(
|
||||
sanitizeTitle("Created task **FN-3058** in the triage column. Here's a summary."),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sanitizeTitle("Created **FN-3058** with the full spec"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("strips quotes, backticks, leading bullets", () => {
|
||||
|
||||
@@ -9075,6 +9075,24 @@ Task with acceptance criteria
|
||||
expect(updatedTask.title).toBe("AI Title");
|
||||
});
|
||||
|
||||
it("should ignore malformed confirmation-prose generated titles", async () => {
|
||||
const mockOnSummarize = vi
|
||||
.fn()
|
||||
.mockResolvedValue("Created task **FN-9999** in the triage column. Here's a summary.");
|
||||
|
||||
const task = await store.createTask(
|
||||
{ description: "a".repeat(201) },
|
||||
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||
);
|
||||
|
||||
expect(task.title).toBeUndefined();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updatedTask = await store.getTask(task.id);
|
||||
expect(updatedTask.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle onSummarize returning null", async () => {
|
||||
const mockOnSummarize = vi.fn().mockResolvedValue(null);
|
||||
|
||||
|
||||
@@ -859,6 +859,12 @@ export function sanitizeTitle(raw: string | undefined | null): string | null {
|
||||
.replace(/(?<![*\w])\*([^*]+)\*(?![*\w])/g, "$1")
|
||||
.replace(/(?<![_\w])_([^_]+)_(?![_\w])/g, "$1");
|
||||
|
||||
// Reject tool/assistant confirmation prose so we never persist
|
||||
// "Created task FN-1234 ..." as a user-visible task title.
|
||||
if (/^created\s+(?:task\s+)?(?:fn-\d+\b|\*\*\s*fn-\d+\s*\*\*)/i.test(title)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Drop trailing punctuation that summary-like sentences leave behind.
|
||||
title = title.replace(/[.!?,;:]+$/, "").trim();
|
||||
if (!title) return null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ensureMemoryFileWithBackend } from "./project-memory.js";
|
||||
import { runCommandAsync } from "./run-command.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { validateNodeOverrideChange } from "./node-override-guard.js";
|
||||
import { sanitizeTitle } from "./ai-summarize.js";
|
||||
|
||||
/** Database row shape for the tasks table (all columns). */
|
||||
interface TaskRow {
|
||||
@@ -2083,7 +2084,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
const generatedTitle = await options.onSummarize!(input.description);
|
||||
const normalizedTitle = generatedTitle?.trim();
|
||||
const normalizedTitle = sanitizeTitle(generatedTitle);
|
||||
if (normalizedTitle) {
|
||||
// Guard against races: read directly from SQLite to avoid extra
|
||||
// prompt/step file I/O in this background path.
|
||||
|
||||
@@ -540,6 +540,19 @@ describe("createTask", () => {
|
||||
expect(body.source).toEqual({ sourceType: "dashboard_ui" });
|
||||
});
|
||||
|
||||
it("serializes priority in createTask payload when provided", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_CREATED_TASK, priority: "urgent" }));
|
||||
|
||||
await createTask({
|
||||
description: "Priority task",
|
||||
priority: "urgent",
|
||||
});
|
||||
|
||||
const call = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
const body = JSON.parse((call[1] as RequestInit).body as string);
|
||||
expect(body.priority).toBe("urgent");
|
||||
});
|
||||
|
||||
it("sends POST with multiple fields including executionMode", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
|
||||
...FAKE_CREATED_TASK,
|
||||
|
||||
@@ -1281,6 +1281,52 @@ describe("POST /tasks", () => {
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards priority when provided", async () => {
|
||||
const createdTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "triage",
|
||||
priority: "high" as const,
|
||||
};
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
description: "Priority task",
|
||||
priority: "high",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Priority task",
|
||||
priority: "high",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid priority value", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
description: "Bad priority",
|
||||
priority: "medium",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("priority must be one of");
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards executionMode when provided with 'fast'", async () => {
|
||||
const createdTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
|
||||
@@ -92,6 +92,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
thinkingLevel,
|
||||
reviewLevel,
|
||||
executionMode,
|
||||
priority,
|
||||
source,
|
||||
} = req.body;
|
||||
if (!description || typeof description !== "string") {
|
||||
@@ -127,6 +128,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
throw badRequest(`executionMode must be one of: ${validExecutionModes.join(", ")}`);
|
||||
}
|
||||
|
||||
// Validate priority if provided.
|
||||
if (priority !== undefined && priority !== null && !isTaskPriority(priority)) {
|
||||
throw badRequest(`priority must be one of: ${TASK_PRIORITIES.join(", ")}`);
|
||||
}
|
||||
|
||||
const executorModel = normalizeModelSelectionPair(validatedModelProvider, validatedModelId);
|
||||
const validatorModel = normalizeModelSelectionPair(validatedValidatorModelProvider, validatedValidatorModelId);
|
||||
const planningModel = normalizeModelSelectionPair(validatedPlanningModelProvider, validatedPlanningModelId);
|
||||
@@ -196,6 +202,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
summarize,
|
||||
reviewLevel: reviewLevel ?? undefined,
|
||||
executionMode: executionMode || undefined,
|
||||
priority: priority ?? undefined,
|
||||
source: normalizedSource,
|
||||
},
|
||||
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } }
|
||||
|
||||
@@ -1335,6 +1335,84 @@ describe("approved triage recovery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("updates malformed metadata title from prompt heading when task ID matches", async () => {
|
||||
await writeFile(
|
||||
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
|
||||
"# Task: FN-001 - Experimental AI Agent Onboarding Flow\n\n**Size:** M\n\n## Review Level: 2\n\nRecovered specification",
|
||||
);
|
||||
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
requirePlanApproval: false,
|
||||
} as Settings),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const recovered = await processor.recoverApprovedTask({
|
||||
id: "FN-001",
|
||||
description: "Recovered triage task",
|
||||
column: "triage",
|
||||
status: "planning",
|
||||
title: "Created task **FN-999** in triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review: APPROVE" }],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:02:00.000Z",
|
||||
});
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({ title: "Experimental AI Agent Onboarding Flow" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not overwrite title when heading task ID does not match", async () => {
|
||||
await writeFile(
|
||||
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
|
||||
"# Task: FN-999 - Wrong Task\n\n**Size:** M\n\n## Review Level: 2\n\nRecovered specification",
|
||||
);
|
||||
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
requirePlanApproval: false,
|
||||
} as Settings),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const recovered = await processor.recoverApprovedTask({
|
||||
id: "FN-001",
|
||||
description: "Recovered triage task",
|
||||
column: "triage",
|
||||
status: "planning",
|
||||
title: "Existing title",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review: APPROVE" }],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:02:00.000Z",
|
||||
});
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.not.objectContaining({ title: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears status and error before moving approved tasks to todo", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1967,6 +1967,11 @@ export class TriageProcessor {
|
||||
taskUpdates.reviewLevel = parseInt(reviewMatch[1], 10);
|
||||
}
|
||||
|
||||
const promptDeclaredTitle = extractPromptDeclaredTitle(written, task.id);
|
||||
if (promptDeclaredTitle) {
|
||||
taskUpdates.title = promptDeclaredTitle;
|
||||
}
|
||||
|
||||
await this.store.updateTask(task.id, taskUpdates);
|
||||
|
||||
if (settings.requirePlanApproval) {
|
||||
@@ -1996,6 +2001,23 @@ export class TriageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
function extractPromptDeclaredTitle(prompt: string, taskId: string): string | null {
|
||||
const headingMatch = prompt.match(/^#\s+Task:\s+([A-Z]+-\d+)\s+-\s+(.+)$/m);
|
||||
if (!headingMatch) return null;
|
||||
const [, headingTaskId, rawTitle] = headingMatch;
|
||||
if (headingTaskId !== taskId) return null;
|
||||
|
||||
const title = rawTitle.trim().replace(/[\s.!?,;:]+$/g, "");
|
||||
if (!title) return null;
|
||||
|
||||
// Conservative guard: do not overwrite metadata with confirmation prose.
|
||||
if (/^created\s+(?:task\s+)?(?:fn-\d+\b|\*\*\s*fn-\d+\s*\*\*)/i.test(title)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
function hasLatestSpecReviewApproval(task: Task): boolean {
|
||||
for (let i = task.log.length - 1; i >= 0; i--) {
|
||||
const action = task.log[i]?.action ?? "";
|
||||
|
||||
Reference in New Issue
Block a user