From a7afb020e9e422fec55400883acf3e1610fa6d05 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Wed, 19 Aug 2026 06:04:49 +0000 Subject: [PATCH] FN-032: use deterministic titles for short descriptions Short untitled task descriptions now receive stable planning titles instead of an empty placeholder. - Derive short-task titles from the first meaningful description line with shared normalization and length safety. - Preserve explicit titles and leave long or empty descriptions on existing AI/none behavior. - Persist the planning heading title through normal task metadata finalization and document the threshold. Files changed: .changeset/fn-032-short-description-title.md | 7 ++ docs/settings-reference.md | 2 +- docs/task-management.md | 4 +- packages/engine/src/__tests__/triage.test.ts | 115 ++++++++++++++++++++++++--- packages/engine/src/triage.ts | 23 +++++- 5 files changed, 137 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-032 Fusion-Task-Lineage: 886f5a32-7eb5-460d-bf1a-93ebf5dc0b93 Co-authored-by: Fusion --- .changeset/fn-032-short-description-title.md | 7 ++ docs/settings-reference.md | 2 +- docs/task-management.md | 4 +- packages/engine/src/__tests__/triage.test.ts | 115 +++++++++++++++++-- packages/engine/src/triage.ts | 23 +++- 5 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 .changeset/fn-032-short-description-title.md diff --git a/.changeset/fn-032-short-description-title.md b/.changeset/fn-032-short-description-title.md new file mode 100644 index 0000000000..1a577be043 --- /dev/null +++ b/.changeset/fn-032-short-description-title.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Give short untitled tasks a deterministic title during planning. +category: fix +dev: Triage derives the title from the first meaningful description line; long-description AI summarization remains unchanged. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index a3cf712090..d404c379b5 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -771,7 +771,7 @@ Database backups work with both external PostgreSQL and Fusion's default embedde | `memoryBackupRetention` | `number` | `14` | Number of memory backups to retain. | | `memoryBackupDir` | `string` | `".fusion/backups/memory"` | Relative memory backup directory path. | | `memoryBackupScope` | `"project" \| "agents" \| "all"` | `"all"` | Backup scope: project memory, agent memory, or both. | -| `autoSummarizeTitles` | `boolean` | `false` | Auto-generate titles for long untitled descriptions across dashboard/API task creation. Generated titles match the operator's input language from the task description. Agent-created tasks from `fn_task_create` and `fn_delegate_task` always request summarization for untitled tasks, regardless of this setting. | +| `autoSummarizeTitles` | `boolean` | `false` | Auto-generate AI titles only for untitled descriptions longer than 200 characters across dashboard/API task creation. Generated titles match the operator's input language from the task description. Shorter untitled descriptions use a setting-independent deterministic planning title, which normal prompt-heading writeback persists. Agent-created tasks from `fn_task_create` and `fn_delegate_task` always request AI summarization for long untitled tasks, regardless of this setting. | | `taskDefinitionInInputLanguage` | `boolean` | `false` | When enabled, generated task-definition (`PROMPT.md`) prose uses a confidently detected supported input language: Spanish (`es`), French (`fr`), Korean (`ko`), or Chinese (`zh-CN`). Only planner-authored prose is localized; headings, markers, the verbatim Original Description, code, paths, tool names, and commit conventions stay canonical English for deterministic parsing. Chinese always authors as `zh-CN`; Traditional Chinese is not variant-detected. English, short/uncertain, and unsupported input such as Japanese fall back to English. Configure in **Settings → Project Models**. | | `useAiMergeCommitSummary` | `boolean` | `true` | Use AI-generated merge commit summaries (subject + bullet body + diff-stat) instead of raw step-commit subject lists. | | `titleSummarizerProvider` | `string` | `undefined` | Provider for title summarization. | diff --git a/docs/task-management.md b/docs/task-management.md index 10a1359923..081c1bd25e 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -929,7 +929,9 @@ Users can apply presets at task creation; manual model selection can override th ## AI Title Summarization -When `autoSummarizeTitles` is enabled and a task has a long untitled description, Fusion can auto-generate a concise title. This applies to tasks created from the dashboard/API as well as tasks created by agents and tooling flows (`fn_task_create`, delegated tasks, and triage-created child tasks). GitHub tracking now waits for the `createTask`-level summarizer (explicit or auto-attached from settings) to settle before filing, then uses that resulting title and falls back to deterministic description-derived title generation only when summarization is unavailable. +Titleless descriptions of 200 characters or fewer do not use AI summarization. During triage, Fusion supplies a setting-independent deterministic title derived from the first meaningful description line (with the shared markdown normalization and 60-character safety cap). The planner's normal `# Task: ID - title` heading is then parsed and written back to the project-scoped task metadata, so board, CLI, and API consumers see the same durable title. + +When `autoSummarizeTitles` is enabled and a task has a long untitled description, Fusion can auto-generate a concise AI title. This applies to tasks created from the dashboard/API as well as tasks created by agents and tooling flows (`fn_task_create`, delegated tasks, and triage-created child tasks). GitHub tracking now waits for the `createTask`-level summarizer (explicit or auto-attached from settings) to settle before filing, then uses that resulting title and falls back to deterministic description-derived title generation only when summarization is unavailable. If a configured title summarizer model is stale after a pi upgrade, Fusion logs a warning naming that provider/model and retries once with automatic model resolution before falling back to deterministic title generation. Genuine AI-service failures are not masked by this retry. diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 6c341653a2..6a272af8d5 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { Agent, TaskStore, Task, TaskDetail, Settings } from "@fusion/core"; -import { applyOriginalDescription, builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core"; +import { applyOriginalDescription, builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, deriveFallbackTaskTitle, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core"; import { TriageProcessor, buildSpecificationPrompt, @@ -530,18 +530,63 @@ describe("buildSpecificationPrompt", () => { expect(prompt).toContain("FN-002, FN-003"); }); - it("handles task without title", () => { - const taskWithoutTitle: TaskDetail = { - ...baseTask, - title: undefined, - }; + describe("short titleless task title fallback", () => { + it.each([199, 200])("uses the deterministic description title at the %i-character boundary", (length) => { + const description = "a".repeat(length); + const prompt = buildSpecificationPrompt( + { ...baseTask, title: undefined, description }, + ".fusion/tasks/KB-001/PROMPT.md", + ); + const fallbackTitle = deriveFallbackTaskTitle(description); - const prompt = buildSpecificationPrompt( - taskWithoutTitle, - ".fusion/tasks/KB-001/PROMPT.md", - ); + expect(prompt).toContain(`- **Title:** ${fallbackTitle}`); + expect(prompt).not.toContain("- **Title:** (none)"); + }); - expect(prompt).toContain("(none)"); + it("preserves an explicit nonblank title", () => { + const prompt = buildSpecificationPrompt( + { ...baseTask, title: "Operator-provided title", description: "short request" }, + ".fusion/tasks/KB-001/PROMPT.md", + ); + + expect(prompt).toContain("- **Title:** Operator-provided title"); + expect(prompt).not.toContain("- **Title:** Short request"); + }); + + it.each([ + ["", "empty"], + [" \n\t", "whitespace-only"], + ])("keeps (none) for %s descriptions", (description) => { + const prompt = buildSpecificationPrompt( + { ...baseTask, title: undefined, description }, + ".fusion/tasks/KB-001/PROMPT.md", + ); + + expect(prompt).toContain("- **Title:** (none)"); + }); + + it("keeps (none) for titleless descriptions over the AI threshold", () => { + const description = "a".repeat(201); + const prompt = buildSpecificationPrompt( + { ...baseTask, title: undefined, description }, + ".fusion/tasks/KB-001/PROMPT.md", + ); + + expect(prompt).toContain("- **Title:** (none)"); + }); + + it("uses the helper-safe first meaningful line for markdown and multiline text", () => { + const description = "\n### Restore the short task title\n\nAdditional details stay in the description."; + const prompt = buildSpecificationPrompt( + { ...baseTask, title: undefined, description }, + ".fusion/tasks/KB-001/PROMPT.md", + ); + const fallbackTitle = deriveFallbackTaskTitle(description); + + expect(fallbackTitle).toBe("Restore the short task title"); + expect(prompt).toContain(`- **Title:** ${fallbackTitle}`); + expect(prompt).not.toContain("- **Title:** (none)"); + }); }); it("includes proactive subtask guidance when breakdown was not explicitly requested", () => { @@ -3620,6 +3665,54 @@ Apply the scoped implementation changes. expect(metadataPatch.intentSignature.filePaths).not.toContain("AtlasNotes.xcodeproj/**"); }); + it("writes the short deterministic planning title through normal heading finalization", async () => { + const description = "Restore the short task title after planning"; + const planningPrompt = buildSpecificationPrompt( + { + ...mockTaskDetail, + id: "FN-001", + title: undefined, + description, + }, + ".fusion/tasks/FN-001/PROMPT.md", + ); + const fallbackTitle = deriveFallbackTaskTitle(description); + expect(planningPrompt).toContain(`- **Title:** ${fallbackTitle}`); + + await writeFile( + join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"), + `# Task: FN-001 - ${fallbackTitle}\n\n**Size:** M\n\n## Steps\n\n### Step 1: Preserve the title\n\nKeep the planned title.`, + ); + + 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, + column: "triage", + status: "planning", + 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: fallbackTitle })); + }); + it("updates malformed metadata title from prompt heading when task ID matches", async () => { await writeFile( join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"), diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index d0225d55f6..177bafa8ef 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -58,6 +58,7 @@ import { isEphemeralAgent, resolveEffectiveAgentPermissionPolicy, MAX_TASK_LIST_TEXT_CHARS, + MIN_DESCRIPTION_LENGTH, deriveFallbackTaskTitle, detectContentLanguage, localeDisplayName, @@ -5422,6 +5423,26 @@ function isMalformedTaskTitle(title: string): boolean { return /^created\s+(?:task\s+)?(?:fn-\d+\b|\*\*\s*fn-\d+\s*\*\*)/i.test(title.trim()); } +/** + * Resolve the title shown to the planner for a task that has not received a title yet. + * + * FNXC:TriageTitleFallback 2026-08-19-05:51: + * Short descriptions never enter AI title summarization, so planning must provide a deterministic + * title instead of `(none)`. Use the shared sanitized first-line helper rather than interpolating + * raw multiline description text, which could corrupt the prompt's `**Title:**` structure. + */ +function resolveSpecificationPromptTitle(task: Pick): string { + const existingTitle = task.title?.trim(); + if (existingTitle) return existingTitle; + + const description = task.description ?? ""; + if (description.trim() && description.length < MIN_DESCRIPTION_LENGTH) { + return deriveFallbackTaskTitle(description); + } + + return "(none)"; +} + function shouldReplaceTaskTitleFromPrompt(task: Task, promptDeclaredTitle: string | null): boolean { if (!promptDeclaredTitle) return false; @@ -5788,7 +5809,7 @@ The authoritative artifact will be stored at \`${promptPath}\`. Do not use the g ## Task - **ID:** ${task.id} -- **Title:** ${task.title || "(none)"} +- **Title:** ${resolveSpecificationPromptTitle(task)} - **Description (current user context):** ${task.description} ${planInput ? `\n## Planning Mode plan.md\n\nTreat this validated lean plan as the primary specification input. Expand it into the full executor-ready PROMPT.md; plan.md is not PROMPT.md.\n\n\`\`\`markdown\n${planInput}\n\`\`\`\n` : ""} ## Original Request