From 583822739b6e4578c1e7a7e22777b2b55fb20242 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 2 May 2026 11:37:15 -0700 Subject: [PATCH] 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 --- .changeset/fn-3056-task-title-sanitization.md | 5 ++ docs/task-management.md | 2 +- .../core/src/__tests__/ai-summarize.test.ts | 16 ++-- packages/core/src/__tests__/store.test.ts | 18 +++++ packages/core/src/ai-summarize.ts | 6 ++ packages/core/src/store.ts | 3 +- packages/dashboard/app/__tests__/api.test.ts | 13 ++++ .../dashboard/src/__tests__/routes.test.ts | 46 +++++++++++ .../routes/register-task-workflow-routes.ts | 7 ++ packages/engine/src/__tests__/triage.test.ts | 78 +++++++++++++++++++ packages/engine/src/triage.ts | 22 ++++++ 11 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-3056-task-title-sanitization.md diff --git a/.changeset/fn-3056-task-title-sanitization.md b/.changeset/fn-3056-task-title-sanitization.md new file mode 100644 index 000000000..9c16d402c --- /dev/null +++ b/.changeset/fn-3056-task-title-sanitization.md @@ -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. \ No newline at end of file diff --git a/docs/task-management.md b/docs/task-management.md index 35b0dacab..440758ab1 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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 diff --git a/packages/core/src/__tests__/ai-summarize.test.ts b/packages/core/src/__tests__/ai-summarize.test.ts index 6de0368f0..3cd431faa 100644 --- a/packages/core/src/__tests__/ai-summarize.test.ts +++ b/packages/core/src/__tests__/ai-summarize.test.ts @@ -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", () => { diff --git a/packages/core/src/__tests__/store.test.ts b/packages/core/src/__tests__/store.test.ts index 9ca6779d9..4c027822d 100644 --- a/packages/core/src/__tests__/store.test.ts +++ b/packages/core/src/__tests__/store.test.ts @@ -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); diff --git a/packages/core/src/ai-summarize.ts b/packages/core/src/ai-summarize.ts index 1d8b47dc7..446c6a2f1 100644 --- a/packages/core/src/ai-summarize.ts +++ b/packages/core/src/ai-summarize.ts @@ -859,6 +859,12 @@ export function sanitizeTitle(raw: string | undefined | null): string | null { .replace(/(? { 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. diff --git a/packages/dashboard/app/__tests__/api.test.ts b/packages/dashboard/app/__tests__/api.test.ts index 22e23afa9..a7003495c 100644 --- a/packages/dashboard/app/__tests__/api.test.ts +++ b/packages/dashboard/app/__tests__/api.test.ts @@ -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, diff --git a/packages/dashboard/src/__tests__/routes.test.ts b/packages/dashboard/src/__tests__/routes.test.ts index f71a4d1d0..25ca471a6 100644 --- a/packages/dashboard/src/__tests__/routes.test.ts +++ b/packages/dashboard/src/__tests__/routes.test.ts @@ -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).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, diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 2f7b13923..a9c17cf82 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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 } } diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 52e946ab7..934de9e38 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -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({ diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 6e557a722..a616ae92a 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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 ?? "";