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:
7
.changeset/fn-7165-refinement-title.md
Normal file
7
.changeset/fn-7165-refinement-title.md
Normal 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.
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user