feat(FN-1530): merge fusion/fn-1530
This commit is contained in:
5
.changeset/fix-async-title-summarize.md
Normal file
5
.changeset/fix-async-title-summarize.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Make auto-summarize titles non-blocking during quick task creation to prevent UI hangs.
|
||||||
@@ -5998,8 +5998,16 @@ Task with acceptance criteria
|
|||||||
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
{ 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);
|
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 () => {
|
it("should not call onSummarize when title is already provided", async () => {
|
||||||
@@ -6059,8 +6067,16 @@ Task with acceptance criteria
|
|||||||
{ onSummarize: mockOnSummarize }
|
{ onSummarize: mockOnSummarize }
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(task.title).toBe("AI Title");
|
// Title is not set synchronously
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
expect(mockOnSummarize).toHaveBeenCalled();
|
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 () => {
|
it("should handle onSummarize returning null", async () => {
|
||||||
@@ -6071,7 +6087,15 @@ Task with acceptance criteria
|
|||||||
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Task created without title
|
||||||
expect(task.title).toBeUndefined();
|
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 () => {
|
it("should handle onSummarize throwing error gracefully", async () => {
|
||||||
@@ -6085,6 +6109,10 @@ Task with acceptance criteria
|
|||||||
|
|
||||||
expect(task.title).toBeUndefined();
|
expect(task.title).toBeUndefined();
|
||||||
expect(task.id).toMatch(/^FN-\d+$/); // Task still created
|
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(/Title summarization failed for task/);
|
||||||
expect(consoleSpy.mock.calls[0][0]).toMatch(/AI service failed/);
|
expect(consoleSpy.mock.calls[0][0]).toMatch(/AI service failed/);
|
||||||
expect(consoleSpy.mock.calls[0][0]).toMatch(/desc length: 201/);
|
expect(consoleSpy.mock.calls[0][0]).toMatch(/desc length: 201/);
|
||||||
@@ -6103,6 +6131,12 @@ Task with acceptance criteria
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOnSummarize).toHaveBeenCalled();
|
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 () => {
|
it("should not trigger summarization at exactly 200 characters", async () => {
|
||||||
@@ -6115,6 +6149,7 @@ Task with acceptance criteria
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOnSummarize).not.toHaveBeenCalled();
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prioritize explicit title over summarize flag", async () => {
|
it("should prioritize explicit title over summarize flag", async () => {
|
||||||
@@ -6137,10 +6172,16 @@ Task with acceptance criteria
|
|||||||
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
{ 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);
|
// Wait for async summarization
|
||||||
expect(detail.prompt).toMatch(/^# FN-\d+: Generated Task Title\n/);
|
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 () => {
|
it("should preserve original description when generating a title", async () => {
|
||||||
@@ -6152,11 +6193,39 @@ Task with acceptance criteria
|
|||||||
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
{ 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);
|
expect(task.description).toBe(originalDescription);
|
||||||
|
|
||||||
const detail = await store.getTask(task.id);
|
// Wait for async summarization
|
||||||
expect(detail.description).toBe(originalDescription);
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1116,30 +1116,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine if we should try to summarize the title
|
// Determine if we should try to summarize the title
|
||||||
let title = input.title?.trim() || undefined;
|
const title = input.title?.trim() || undefined;
|
||||||
const shouldSummarize =
|
const shouldSummarize =
|
||||||
!title && // Only if no title provided
|
!title && // Only if no title provided
|
||||||
input.description.length > 200 && // Only if description is long enough
|
input.description.length > 200 && // Only if description is long enough
|
||||||
(input.summarize === true || // Explicit request
|
(input.summarize === true || // Explicit request
|
||||||
options?.settings?.autoSummarizeTitles === true); // Auto-enabled
|
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
|
// Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps
|
||||||
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
||||||
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
||||||
@@ -1163,6 +1146,46 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
resolvedWorkflowSteps = undefined;
|
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 now = new Date().toISOString();
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
id,
|
id,
|
||||||
|
|||||||
Reference in New Issue
Block a user