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:
7
.changeset/fn-032-short-description-title.md
Normal file
7
.changeset/fn-032-short-description-title.md
Normal 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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user