FN-7165: title refinement tasks with source feedback

Refinement tasks now surface the source task ID and operator feedback in their generated titles.

- Build refinement titles from the source task ID plus normalized feedback instead of the source title or description fallback.
- Preserve the source-ID prefix while capping titles at MAX_TITLE_LENGTH and leaving refinement descriptions unchanged.
- Update refinement task and prompt-heading tests for titled, untitled, whitespace-heavy, and long-feedback cases.
- Add a changeset for the published CLI package.

Files changed:
 .changeset/fn-7165-refinement-title.md           |  7 +++
 packages/core/src/__tests__/store-create.test.ts |  2 +-
 packages/core/src/__tests__/store-ops.test.ts    | 75 +++++++++++++++++-------
 packages/core/src/store.ts                       | 28 ++++-----
 4 files changed, 71 insertions(+), 41 deletions(-)

Fusion-Task-Id: FN-7165

Fusion-Task-Lineage: abb16dae-26d1-4600-bb19-e2634ab16795

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 22:07:07 -07:00
parent bee93c3e2b
commit 34efa8b89a
4 changed files with 71 additions and 41 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Refinement tasks are now titled with the source task ID followed by the entered comment.
category: feature
dev: TaskStore.refineTask now sets title = "{sourceId}: {feedback}"; normalization is skipped to preserve the source-id prefix; FN-7165.

View File

@@ -263,7 +263,7 @@ describe("TaskStore", () => {
const refined = await store.refineTask(source.id, "Needs polish");
expect(refined.sourceType).toBe("task_refine");
expect(refined.sourceParentTaskId).toBe(source.id);
expect(refined.title).toBe("Refinement: Fix bug");
expect(refined.title).toBe(`${source.id}: Needs polish`);
});
it("FN-4898: prevents title/ID drift on duplicateTask", async () => {

View File

@@ -11,6 +11,7 @@ import { TaskStore, TaskHasDependentsError } from "../store.js";
import type { runCommandAsync } from "../run-command.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { setTaskCreatedHook } from "../task-creation-hooks.js";
import { MAX_TITLE_LENGTH } from "../ai-summarize.js";
describe("TaskStore", () => {
const harness = createSharedTaskStoreTestHarness();
@@ -308,8 +309,8 @@ describe("TaskStore", () => {
expect(refined.id).not.toBe(task.id);
expect(refined.id).toMatch(/^FN-\d+$/);
expect(refined.column).toBe("triage");
// Untitled source: uses first line of description as readable label
expect(refined.title).toBe("Refinement: Original task");
expect(refined.title).toBe(`${task.id}: Need to fix edge case`);
expect(refined.title).not.toContain("Refinement:");
});
it("creates refinement from in-review task", async () => {
@@ -321,8 +322,8 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.column).toBe("triage");
// Untitled source: uses first line of description as readable label
expect(refined.title).toBe("Refinement: Original task");
expect(refined.title).toBe(`${task.id}: Need improvements`);
expect(refined.title).not.toContain("Refinement:");
});
it("throws error when refining task in triage", async () => {
@@ -367,7 +368,7 @@ describe("TaskStore", () => {
await expect(store.refineTask(task.id, " ")).rejects.toThrow("Feedback is required");
});
it("sets correct title format with original title", async () => {
it("sets correct title format with source id and feedback when original title exists", async () => {
const task = await store.createTask({ title: "My Feature", description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
@@ -376,10 +377,11 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "Add more tests");
expect(refined.title).toBe("Refinement: My Feature");
expect(refined.title).toBe(`${task.id}: Add more tests`);
expect(refined.title.startsWith(`${task.id}: `)).toBe(true);
});
it("sets correct title format without original title (uses description fallback)", async () => {
it("sets correct title format without original title", async () => {
const task = await store.createTask({ description: "Fix the login bug" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
@@ -388,8 +390,7 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "Add more tests");
// Falls back to first line of description when no title
expect(refined.title).toBe("Refinement: Fix the login bug");
expect(refined.title).toBe(`${task.id}: Add more tests`);
});
it("description includes feedback and refines reference", async () => {
@@ -592,13 +593,13 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
// Untitled source: uses first line of description
expect(detail.prompt).toContain("Refinement: Original task");
expect(detail.prompt).toContain(`${task.id}: Need improvements`);
expect(detail.prompt).not.toContain("Refinement: Original task");
expect(detail.prompt).toContain("Need improvements");
expect(detail.prompt).toContain(`Refines: ${task.id}`);
});
it("uses first non-empty line of description when title is absent", async () => {
it("uses feedback instead of first non-empty line of description when title is absent", async () => {
const task = await store.createTask({
description: "Use source task labels for refinement titles\n\nThis is a longer description.",
});
@@ -609,10 +610,10 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "Add more tests");
expect(refined.title).toBe("Refinement: Use source task labels for refinement titles");
expect(refined.title).toBe(`${task.id}: Add more tests`);
});
it("collapses internal whitespace in description fallback", async () => {
it("uses feedback instead of collapsing description fallback whitespace", async () => {
const task = await store.createTask({
description: "Fix the \t spacing issue in UI",
});
@@ -623,7 +624,7 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "More feedback");
expect(refined.title).toBe("Refinement: Fix the spacing issue in UI");
expect(refined.title).toBe(`${task.id}: More feedback`);
});
it("skips leading blank lines in multi-line description", async () => {
@@ -637,7 +638,7 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "Feedback");
expect(refined.title).toBe("Refinement: First real line of description");
expect(refined.title).toBe(`${task.id}: Feedback`);
});
it("falls back to task ID when description has no non-empty lines", async () => {
@@ -652,7 +653,7 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "Feedback");
expect(refined.title).toBe("Refinement");
expect(refined.title).toBe(`${task.id}: Feedback`);
});
it("PROMPT.md heading matches the refinement title", async () => {
@@ -668,11 +669,12 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
expect(refined.title).toBe("Refinement: My Feature");
expect(detail.prompt).toMatch(/^# Refinement: My Feature\n/);
expect(refined.title).toBe(`${task.id}: Need improvements`);
expect(refined.title.startsWith(`${task.id}: `)).toBe(true);
expect(detail.prompt).toMatch(new RegExp(`^# ${task.id}: Need improvements\\n`));
});
it("PROMPT.md heading uses description fallback when untitled", async () => {
it("PROMPT.md heading uses source id and feedback when untitled", async () => {
const task = await store.createTask({
description: "Fix the login bug on settings page",
});
@@ -684,8 +686,37 @@ describe("TaskStore", () => {
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
expect(refined.title).toBe("Refinement: Fix the login bug on settings page");
expect(detail.prompt).toMatch(/^# Refinement: Fix the login bug on settings page\n/);
expect(refined.title).toBe(`${task.id}: Need improvements`);
expect(detail.prompt).toMatch(new RegExp(`^# ${task.id}: Need improvements\\n`));
});
it("collapses multi-line and whitespace-heavy feedback in the refinement title", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, " Fix the\n\t failing edge case ");
expect(refined.title).toBe(`${task.id}: Fix the failing edge case`);
expect(refined.title).not.toContain("\n");
expect(refined.description).toBe(`Fix the\n\t failing edge case\n\nRefines: ${task.id}`);
});
it("caps long refinement titles at MAX_TITLE_LENGTH while preserving the source prefix", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const feedback = "Improve the failing edge case by adding comprehensive validation around stale payloads";
const refined = await store.refineTask(task.id, feedback);
expect(refined.title).toHaveLength(MAX_TITLE_LENGTH);
expect(refined.title).toBe(`${task.id}: ${feedback}`.slice(0, MAX_TITLE_LENGTH).trim());
expect(refined.title.startsWith(`${task.id}: `)).toBe(true);
});
it("throws ENOENT when source task does not exist", async () => {

View File

@@ -179,7 +179,7 @@ import {
import { truncateAgentLogDetail } from "./agent-log-constants.js";
import { emitUsageEvent as emitUsageEventToDb, type UsageEventInput } from "./usage-events.js";
import { validateNodeOverrideChange } from "./node-override-guard.js";
import { sanitizeTitle, summarizeTitle } from "./ai-summarize.js";
import { MAX_TITLE_LENGTH, sanitizeTitle, summarizeTitle } from "./ai-summarize.js";
import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-drift.js";
import { resolveTitleSummarizerSettingsModel } from "./model-resolution.js";
import { resolveEffectiveSettingsById } from "./workflow-settings-resolver.js";
@@ -5188,25 +5188,17 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
}
const now = new Date().toISOString();
let sourceLabel: string;
if (sourceTask.title?.trim()) {
sourceLabel = sourceTask.title.trim();
} else {
const firstLine = sourceTask.description
.split("\n")
.map((line: string) => line.trim())
.find((line: string) => line.length > 0);
sourceLabel = firstLine ? firstLine.replace(/\s+/g, " ") : sourceTask.id;
}
const normalizedFeedback = feedback.trim().replace(/\s+/g, " ");
/**
* FNXC:TaskRefinement 2026-06-27-21:49:
* Refinement titles must encode the source task id followed by the operator-entered feedback for immediate traceability.
* Do not run title-id drift normalization here: the leading source-id prefix intentionally differs from the new refinement task id and must be preserved.
* The source-id prefix and separator count toward MAX_TITLE_LENGTH, leaving the remaining budget for feedback text.
*/
const refinementTitle = `${id}: ${normalizedFeedback}`.slice(0, MAX_TITLE_LENGTH).trim();
return this.createTaskWithDistributedReservation({ description: feedback.trim() }, {
createTaskWithId: async (newId, reservationCommit) => {
// FN-5077: keep deterministic "Refinement" fallback when normalized refinement label is unusable (null).
const normalizedTitle = normalizeTitleForTaskId(`Refinement: ${sourceLabel}`, newId);
if (normalizedTitle.changed) {
const removed = extractTaskIdTokens(`Refinement: ${sourceLabel}`).filter((token) => token !== newId.toUpperCase());
storeLog.log(`[title-id-drift] normalized title for ${newId}: removed=[${removed.join(",")}]`);
}
const sourceGithubLinked = sourceTask.githubTracking?.enabled === true || Boolean(sourceTask.githubTracking?.issue);
// FN-5780: refinement should inherit source linking intent so unlinked tasks stay opted out from auto-create defaults.
const refinementGithubTracking = sourceGithubLinked
@@ -5221,7 +5213,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
const newTask: Task = {
id: newId,
lineageId: generateTaskLineageId(),
title: normalizedTitle.title ?? "Refinement",
title: refinementTitle,
description: `${feedback.trim()}\n\nRefines: ${id}`,
priority: normalizeTaskPriority(sourceTask.priority),
column: "triage",