fix(FN-1572): stabilize fusion agent execution

This commit is contained in:
gsxdsm
2026-04-17 12:13:39 -07:00
parent 9bcefcb3a9
commit f35ea101c1
29 changed files with 657 additions and 283 deletions

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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",
}),
},
);
});