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 9c45d2410b
commit 6b4f28a1fb
11 changed files with 205 additions and 11 deletions

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.