feat(FN-1530): merge fusion/fn-1530

This commit is contained in:
gsxdsm
2026-04-14 08:14:19 -07:00
parent ad363d8441
commit 6667fd93c2
3 changed files with 123 additions and 26 deletions

View File

@@ -5998,8 +5998,16 @@ Task with acceptance criteria
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBe("AI Generated Title");
// Title is not set synchronously - summarization happens async
expect(task.title).toBeUndefined();
expect(mockOnSummarize).toHaveBeenCalledWith(longDescription);
// Wait for async summarization to complete
await new Promise((resolve) => setTimeout(resolve, 10));
// Verify title was set asynchronously
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("AI Generated Title");
});
it("should not call onSummarize when title is already provided", async () => {
@@ -6059,8 +6067,16 @@ Task with acceptance criteria
{ onSummarize: mockOnSummarize }
);
expect(task.title).toBe("AI Title");
// Title is not set synchronously
expect(task.title).toBeUndefined();
expect(mockOnSummarize).toHaveBeenCalled();
// Wait for async summarization to complete
await new Promise((resolve) => setTimeout(resolve, 10));
// Verify title was set asynchronously
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("AI Title");
});
it("should handle onSummarize returning null", async () => {
@@ -6071,7 +6087,15 @@ Task with acceptance criteria
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
// Task created without title
expect(task.title).toBeUndefined();
// Wait for async summarization to complete
await new Promise((resolve) => setTimeout(resolve, 10));
// Title should remain undefined
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBeUndefined();
});
it("should handle onSummarize throwing error gracefully", async () => {
@@ -6085,6 +6109,10 @@ Task with acceptance criteria
expect(task.title).toBeUndefined();
expect(task.id).toMatch(/^FN-\d+$/); // Task still created
// Wait for async error to be logged
await new Promise((resolve) => setTimeout(resolve, 10));
expect(consoleSpy.mock.calls[0][0]).toMatch(/Title summarization failed for task/);
expect(consoleSpy.mock.calls[0][0]).toMatch(/AI service failed/);
expect(consoleSpy.mock.calls[0][0]).toMatch(/desc length: 201/);
@@ -6103,6 +6131,12 @@ Task with acceptance criteria
);
expect(mockOnSummarize).toHaveBeenCalled();
// Wait for async summarization
await new Promise((resolve) => setTimeout(resolve, 10));
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("AI Title");
});
it("should not trigger summarization at exactly 200 characters", async () => {
@@ -6115,6 +6149,7 @@ Task with acceptance criteria
);
expect(mockOnSummarize).not.toHaveBeenCalled();
expect(task.title).toBeUndefined();
});
it("should prioritize explicit title over summarize flag", async () => {
@@ -6137,10 +6172,16 @@ Task with acceptance criteria
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBe("Generated Task Title");
// Title not set synchronously
expect(task.title).toBeUndefined();
const detail = await store.getTask(task.id);
expect(detail.prompt).toMatch(/^# FN-\d+: Generated Task Title\n/);
// Wait for async summarization
await new Promise((resolve) => setTimeout(resolve, 10));
// Verify title and PROMPT.md were updated
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("Generated Task Title");
expect(updatedTask.prompt).toMatch(/^# FN-\d+: Generated Task Title\n/);
});
it("should preserve original description when generating a title", async () => {
@@ -6152,11 +6193,39 @@ Task with acceptance criteria
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBe("AI Summary Title");
// Title not set synchronously
expect(task.title).toBeUndefined();
expect(task.description).toBe(originalDescription);
const detail = await store.getTask(task.id);
expect(detail.description).toBe(originalDescription);
// Wait for async summarization
await new Promise((resolve) => setTimeout(resolve, 10));
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("AI Summary Title");
expect(updatedTask.description).toBe(originalDescription);
});
it("should not overwrite user-set title during async summarization", async () => {
const mockOnSummarize = vi.fn().mockImplementation(async () => {
// Simulate slow AI response
await new Promise((resolve) => setTimeout(resolve, 50));
return "AI Title";
});
const task = await store.createTask(
{ description: "a".repeat(201) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
// Immediately update with user title
await store.updateTask(task.id, { title: "User Title" });
// Wait for delayed onSummarize to resolve
await new Promise((resolve) => setTimeout(resolve, 100));
// Title should still be "User Title" (race guard should have prevented overwrite)
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("User Title");
});
});

View File

@@ -1116,30 +1116,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
// Determine if we should try to summarize the title
let title = input.title?.trim() || undefined;
const title = input.title?.trim() || undefined;
const shouldSummarize =
!title && // Only if no title provided
input.description.length > 200 && // Only if description is long enough
(input.summarize === true || // Explicit request
options?.settings?.autoSummarizeTitles === true); // Auto-enabled
if (shouldSummarize && options?.onSummarize) {
try {
const generatedTitle = await options.onSummarize(input.description);
if (generatedTitle) {
title = generatedTitle;
}
} catch (err) {
// Log warning but don't block task creation
const errorMsg = err instanceof Error ? err.message : String(err);
const autoEnabled = options?.settings?.autoSummarizeTitles === true;
console.warn(
`[TaskStore] Title summarization failed for task ${id}: ${errorMsg}` +
` (desc length: ${input.description.length}, auto-summarize: ${autoEnabled})`
);
}
}
// Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
@@ -1163,6 +1146,46 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
resolvedWorkflowSteps = undefined;
}
// Create the task immediately with current title (may be undefined)
const task = await this._createTaskInternal(input, title, resolvedWorkflowSteps, id);
// Fire async background handler for title summarization (non-blocking)
if (shouldSummarize && options?.onSummarize) {
Promise.resolve().then(async () => {
try {
const generatedTitle = await options.onSummarize!(input.description);
if (generatedTitle) {
// Guard against races: fetch current task and only update if no title set
const currentTask = await this.getTask(id);
if (currentTask && !currentTask.title) {
await this.updateTask(id, { title: generatedTitle });
}
}
} catch (err) {
// Log warning but don't crash
const errorMsg = err instanceof Error ? err.message : String(err);
const autoEnabled = options?.settings?.autoSummarizeTitles === true;
console.warn(
`[TaskStore] Title summarization failed for task ${id}: ${errorMsg}` +
` (desc length: ${input.description.length}, auto-summarize: ${autoEnabled})`
);
}
}).catch(() => {}); // Prevent unhandled rejection
}
return task;
}
/**
* Internal helper for task creation. Used by createTask() and potentially other
* internal methods that need to create tasks without triggering summarization.
*/
private async _createTaskInternal(
input: TaskCreateInput,
title: string | undefined,
resolvedWorkflowSteps: string[] | undefined,
id: string
): Promise<Task> {
const now = new Date().toISOString();
const task: Task = {
id,