fix(FN-1572): stabilize fusion agent execution
This commit is contained in:
@@ -237,26 +237,19 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
setSavingTarget(target);
|
||||
|
||||
try {
|
||||
const updatedTask = await updateTask(requestTaskId, {
|
||||
modelProvider: target === "executor"
|
||||
? nextSelection.provider ?? null
|
||||
: previousSavedExecutor.provider ?? null,
|
||||
modelId: target === "executor"
|
||||
? nextSelection.modelId ?? null
|
||||
: previousSavedExecutor.modelId ?? null,
|
||||
validatorModelProvider: target === "validator"
|
||||
? nextSelection.provider ?? null
|
||||
: previousSavedValidator.provider ?? null,
|
||||
validatorModelId: target === "validator"
|
||||
? nextSelection.modelId ?? null
|
||||
: previousSavedValidator.modelId ?? null,
|
||||
planningModelProvider: target === "planning"
|
||||
? nextSelection.provider ?? null
|
||||
: previousSavedPlanning.provider ?? null,
|
||||
planningModelId: target === "planning"
|
||||
? nextSelection.modelId ?? null
|
||||
: previousSavedPlanning.modelId ?? null,
|
||||
});
|
||||
const updates: Parameters<typeof updateTask>[1] = {};
|
||||
if (target === "executor") {
|
||||
updates.modelProvider = nextSelection.provider ?? null;
|
||||
updates.modelId = nextSelection.modelId ?? null;
|
||||
} else if (target === "validator") {
|
||||
updates.validatorModelProvider = nextSelection.provider ?? null;
|
||||
updates.validatorModelId = nextSelection.modelId ?? null;
|
||||
} else {
|
||||
updates.planningModelProvider = nextSelection.provider ?? null;
|
||||
updates.planningModelId = nextSelection.modelId ?? null;
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(requestTaskId, updates);
|
||||
|
||||
if (activeTaskIdRef.current !== requestTaskId) {
|
||||
return;
|
||||
|
||||
@@ -193,6 +193,19 @@ function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||
}
|
||||
|
||||
function sameStringArray(a: string[] = [], b: string[] = []): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
function splitModelSelection(value: string): { provider: string; modelId: string } | null {
|
||||
const slashIdx = value.indexOf("/");
|
||||
if (!value || slashIdx === -1) return null;
|
||||
return {
|
||||
provider: value.slice(0, slashIdx),
|
||||
modelId: value.slice(slashIdx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
const DESCRIPTION_TRUNCATE_LENGTH = 200;
|
||||
|
||||
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||
@@ -529,26 +542,54 @@ export function TaskDetailModal({
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Build update payload with all changed fields
|
||||
const executorSlashIdx = editExecutorModel.indexOf("/");
|
||||
const validatorSlashIdx = editValidatorModel.indexOf("/");
|
||||
const planningSlashIdx = editPlanningModel.indexOf("/");
|
||||
const updates: Parameters<typeof updateTask>[1] = {};
|
||||
const trimmedTitle = editTitle.trim();
|
||||
const trimmedDescription = editDescription.trim();
|
||||
|
||||
const updates: Parameters<typeof updateTask>[1] = {
|
||||
title: editTitle.trim() || undefined,
|
||||
description: editDescription.trim() || undefined,
|
||||
dependencies: editDependencies,
|
||||
enabledWorkflowSteps: editSelectedWorkflowSteps,
|
||||
modelProvider: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(0, executorSlashIdx) : null,
|
||||
modelId: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(executorSlashIdx + 1) : null,
|
||||
validatorModelProvider: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(0, validatorSlashIdx) : null,
|
||||
validatorModelId: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(validatorSlashIdx + 1) : null,
|
||||
planningModelProvider: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(0, planningSlashIdx) : null,
|
||||
planningModelId: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(planningSlashIdx + 1) : null,
|
||||
thinkingLevel: editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null,
|
||||
};
|
||||
if (trimmedTitle && trimmedTitle !== (task.title ?? "")) {
|
||||
updates.title = trimmedTitle;
|
||||
}
|
||||
if (trimmedDescription && trimmedDescription !== (task.description ?? "")) {
|
||||
updates.description = trimmedDescription;
|
||||
}
|
||||
if (!sameStringArray(editDependencies, task.dependencies ?? [])) {
|
||||
updates.dependencies = editDependencies;
|
||||
}
|
||||
if (!sameStringArray(editSelectedWorkflowSteps, task.enabledWorkflowSteps ?? [])) {
|
||||
updates.enabledWorkflowSteps = editSelectedWorkflowSteps;
|
||||
}
|
||||
|
||||
await updateTask(task.id, updates, projectId);
|
||||
const executorSelection = splitModelSelection(editExecutorModel);
|
||||
const currentExecutorModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
|
||||
if (editExecutorModel !== currentExecutorModel) {
|
||||
updates.modelProvider = executorSelection?.provider ?? null;
|
||||
updates.modelId = executorSelection?.modelId ?? null;
|
||||
}
|
||||
|
||||
const validatorSelection = splitModelSelection(editValidatorModel);
|
||||
const currentValidatorModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
|
||||
if (editValidatorModel !== currentValidatorModel) {
|
||||
updates.validatorModelProvider = validatorSelection?.provider ?? null;
|
||||
updates.validatorModelId = validatorSelection?.modelId ?? null;
|
||||
}
|
||||
|
||||
const planningSelection = splitModelSelection(editPlanningModel);
|
||||
const currentPlanningModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
|
||||
if (editPlanningModel !== currentPlanningModel) {
|
||||
updates.planningModelProvider = planningSelection?.provider ?? null;
|
||||
updates.planningModelId = planningSelection?.modelId ?? null;
|
||||
}
|
||||
|
||||
const currentThinkingLevel = task.thinkingLevel ?? "";
|
||||
if (editThinkingLevel !== currentThinkingLevel) {
|
||||
updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null;
|
||||
}
|
||||
|
||||
const hasTaskUpdates = Object.keys(updates).length > 0;
|
||||
if (hasTaskUpdates) {
|
||||
const updatedTask = await updateTask(task.id, updates, projectId);
|
||||
onTaskUpdated?.(updatedTask);
|
||||
}
|
||||
|
||||
// Upload pending images as attachments
|
||||
if (editPendingImages.length > 0) {
|
||||
@@ -578,7 +619,7 @@ export function TaskDetailModal({
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
}, [task.id, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId]);
|
||||
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId, onTaskUpdated]);
|
||||
|
||||
const handleAutoSaveDescription = useCallback(async (description: string) => {
|
||||
try {
|
||||
|
||||
@@ -4134,6 +4134,9 @@ describe("TaskDetailModal", () => {
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "Changed title" } });
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
@@ -4163,6 +4166,9 @@ describe("TaskDetailModal", () => {
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "Changed title" } });
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
@@ -4196,6 +4202,9 @@ describe("TaskDetailModal", () => {
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "Changed title" } });
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
@@ -4276,7 +4285,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("save sends all changed fields via updateTask", async () => {
|
||||
it("save sends only changed fields via updateTask", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
|
||||
@@ -4296,16 +4305,16 @@ describe("TaskDetailModal", () => {
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
|
||||
fireEvent.change(descTextarea, { target: { value: "Updated desc" } });
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
||||
title: "Test",
|
||||
description: "Desc",
|
||||
dependencies: ["FN-002"],
|
||||
enabledWorkflowSteps: [],
|
||||
}), undefined);
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", {
|
||||
description: "Updated desc",
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4408,12 +4417,10 @@ describe("TaskDetailModal", () => {
|
||||
expect(mockUpdateTask).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"FN-001",
|
||||
expect.objectContaining({
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
{
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user