diff --git a/.changeset/fn-7165-refinement-title.md b/.changeset/fn-7165-refinement-title.md new file mode 100644 index 0000000000..620ab0e681 --- /dev/null +++ b/.changeset/fn-7165-refinement-title.md @@ -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. diff --git a/packages/core/src/__tests__/store-create.test.ts b/packages/core/src/__tests__/store-create.test.ts index 95a4bf4d6f..20afa6aa3e 100644 --- a/packages/core/src/__tests__/store-create.test.ts +++ b/packages/core/src/__tests__/store-create.test.ts @@ -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 () => { diff --git a/packages/core/src/__tests__/store-ops.test.ts b/packages/core/src/__tests__/store-ops.test.ts index 945a69f5a5..39d58c99a6 100644 --- a/packages/core/src/__tests__/store-ops.test.ts +++ b/packages/core/src/__tests__/store-ops.test.ts @@ -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 () => { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 691048717f..fec3670a94 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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",