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:
Fusion
2026-05-02 11:37:15 -07:00
committed by gsxdsm
parent 50238ae7eb
commit 583822739b
11 changed files with 205 additions and 11 deletions

View 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.

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 } }

View File

@@ -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({

View File

@@ -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 ?? "";