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 <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 06:04:49 +00:00
parent c3ff663c1c
commit a7afb020e9
5 changed files with 137 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<TaskDetail, "title" | "description">): 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