From 45daf4478ccbdaf9072de796ec4aca746770acb1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 1 Aug 2026 10:53:52 -0700 Subject: [PATCH] FN-8710: cover credential instances in model selector saves Add regression coverage for credential-instance persistence across task model selector lanes. - Assert lane model saves explicitly clear stale credential instances. - Cover selecting an available executor credential instance and replacing it with a new model. - Verify inline task detail updates preserve and restore credential-instance state. Files changed: .../components/__tests__/ModelSelectorTab.test.tsx | 68 ++++++++++++++++++++++ ...lModal.inline-editing-and-integrations.test.tsx | 51 ++++++++++++++-- 2 files changed, 115 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8710 Fusion-Task-Lineage: 35295b90-2aa7-4258-a170-9aaca537bb7a Co-authored-by: Fusion (runfusion.ai) --- .../__tests__/ModelSelectorTab.test.tsx | 68 +++++++++++++++++++ ...l.inline-editing-and-integrations.test.tsx | 53 +++++++++++++-- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx b/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx index 216f6f950b..ad64a45716 100644 --- a/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx @@ -35,6 +35,10 @@ async function selectDropdownOption(user: ReturnType, la await user.click(within(listbox).getByText(option)); } +/* +FNXC:TaskDetailModels 2026-08-01-17:12: +Every direct task-model save owns its lane's provider, model, and credential-instance tuple. An absent or stale instance is deliberately persisted as null so task execution cannot retain a credential override after its model selection changes. +*/ describe("ModelSelectorTab", () => { beforeEach(() => { vi.clearAllMocks(); @@ -57,6 +61,7 @@ describe("ModelSelectorTab", () => { ...task, modelProvider: "pi-claude-cli", modelId: "claude-sonnet-5", + credentialInstanceId: null, }); render( @@ -82,10 +87,12 @@ describe("ModelSelectorTab", () => { expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", { modelProvider: "pi-claude-cli", modelId: "claude-sonnet-5", + credentialInstanceId: null, }, undefined); expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({ modelProvider: "pi-claude-cli", modelId: "claude-sonnet-5", + credentialInstanceId: null, })); }); }); @@ -97,12 +104,16 @@ describe("ModelSelectorTab", () => { const task = makeTask({ modelProvider: "pi-claude-cli", modelId: "claude-haiku-5", + credentialInstanceId: "executor-stale", validatorModelProvider: "pi-claude-cli", validatorModelId: "claude-haiku-5", + validatorCredentialInstanceId: "reviewer-stale", planningModelProvider: "pi-claude-cli", planningModelId: "claude-haiku-5", + planningCredentialInstanceId: "planning-stale", mergerModelProvider: "pi-claude-cli", mergerModelId: "claude-haiku-5", + mergerCredentialInstanceId: "merger-stale", thinkingLevel: "minimal", }); @@ -119,21 +130,25 @@ describe("ModelSelectorTab", () => { ...task, modelProvider: "pi-claude-cli", modelId: "claude-sonnet-5", + credentialInstanceId: null, }) .mockResolvedValueOnce({ ...task, validatorModelProvider: "pi-claude-cli", validatorModelId: "claude-sonnet-5", + validatorCredentialInstanceId: null, }) .mockResolvedValueOnce({ ...task, planningModelProvider: "pi-claude-cli", planningModelId: "claude-sonnet-5", + planningCredentialInstanceId: null, }) .mockResolvedValueOnce({ ...task, mergerModelProvider: "pi-claude-cli", mergerModelId: "claude-sonnet-5", + mergerCredentialInstanceId: null, }) .mockResolvedValueOnce({ ...task, @@ -168,6 +183,7 @@ describe("ModelSelectorTab", () => { expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-7398", { modelProvider: "pi-claude-cli", modelId: "claude-sonnet-5", + credentialInstanceId: null, }, "project-alpha"); }); @@ -176,6 +192,7 @@ describe("ModelSelectorTab", () => { expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-7398", { validatorModelProvider: "pi-claude-cli", validatorModelId: "claude-sonnet-5", + validatorCredentialInstanceId: null, }, "project-alpha"); }); @@ -184,6 +201,7 @@ describe("ModelSelectorTab", () => { expect(mockUpdateTask).toHaveBeenNthCalledWith(3, "FN-7398", { planningModelProvider: "pi-claude-cli", planningModelId: "claude-sonnet-5", + planningCredentialInstanceId: null, }, "project-alpha"); }); @@ -192,6 +210,7 @@ describe("ModelSelectorTab", () => { expect(mockUpdateTask).toHaveBeenNthCalledWith(4, "FN-7398", { mergerModelProvider: "pi-claude-cli", mergerModelId: "claude-sonnet-5", + mergerCredentialInstanceId: null, }, "project-alpha"); }); @@ -234,11 +253,13 @@ describe("ModelSelectorTab", () => { const task = makeTask({ modelProvider: "pi-claude-cli", modelId: "claude-sonnet-5", + credentialInstanceId: "executor-stale", }); mockUpdateTask.mockResolvedValueOnce({ ...task, modelProvider: null, modelId: null, + credentialInstanceId: null, }); render( @@ -257,6 +278,53 @@ describe("ModelSelectorTab", () => { expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", { modelProvider: null, modelId: null, + credentialInstanceId: null, + }, "project-alpha"); + }); + }); + + it("persists populated instances and clears stale instances with their executor tuple", async () => { + const user = userEvent.setup(); + const task = makeTask({ + modelProvider: "anthropic", + modelId: "claude-haiku-5", + credentialInstanceId: "anthropic-primary", + }); + mockFetchModels.mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-haiku-5", name: "Claude Haiku 5", reasoning: true, contextWindow: 200_000 }, + { provider: "anthropic", id: "claude-sonnet-5", name: "Claude Sonnet 5", reasoning: true, contextWindow: 1_000_000 }, + ], + favoriteProviders: [], + favoriteModels: [], + providerInstances: { + anthropic: { instances: [{ id: "anthropic-primary", isDefault: true }, { id: "anthropic-secondary", isDefault: false }] }, + }, + }); + mockUpdateTask + .mockResolvedValueOnce({ ...task, credentialInstanceId: "anthropic-secondary" }) + .mockResolvedValueOnce({ ...task, modelId: "claude-sonnet-5", credentialInstanceId: null }); + + render(); + + await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument()); + await user.click(screen.getByLabelText("Executor Model")); + await user.selectOptions(await screen.findByTestId("custom-model-dropdown-credential-instance"), "anthropic-secondary"); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-7398", { + modelProvider: "anthropic", + modelId: "claude-haiku-5", + credentialInstanceId: "anthropic-secondary", + }, "project-alpha"); + }); + + await user.keyboard("{Escape}"); + await selectDropdownOption(user, "Executor Model", "Claude Sonnet 5"); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-7398", { + modelProvider: "anthropic", + modelId: "claude-sonnet-5", + credentialInstanceId: null, }, "project-alpha"); }); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx index 6d95103017..63a2a30e39 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx @@ -1784,6 +1784,9 @@ describe("TaskDetailModal", () => { models: availableModels, favoriteProviders: [], favoriteModels: [], + providerInstances: { + anthropic: { instances: [{ id: "anthropic-primary", isDefault: true }, { id: "anthropic-secondary", isDefault: false }] }, + }, }); const initialTask = makeTask({ id: "FN-001", column: "triage", title: "Model sync test" }); @@ -1791,15 +1794,22 @@ describe("TaskDetailModal", () => { ...initialTask, modelProvider: "anthropic", modelId: "claude-sonnet-4-5", + credentialInstanceId: null, + }; + const updatedAfterExecutorInstance: Task = { + ...updatedAfterExecutor, + credentialInstanceId: "anthropic-secondary", }; const updatedAfterValidator: Task = { - ...updatedAfterExecutor, + ...updatedAfterExecutorInstance, validatorModelProvider: "openai", validatorModelId: "gpt-4o", + validatorCredentialInstanceId: null, }; mockUpdateTask .mockResolvedValueOnce(updatedAfterExecutor) + .mockResolvedValueOnce(updatedAfterExecutorInstance) .mockResolvedValueOnce(updatedAfterValidator); const addToast = vi.fn(); @@ -1842,30 +1852,55 @@ describe("TaskDetailModal", () => { expect(mockUpdateTask).toHaveBeenNthCalledWith( 1, "FN-001", - expect.objectContaining({ + { modelProvider: "anthropic", modelId: "claude-sonnet-4-5", - }), + credentialInstanceId: null, + }, "project-alpha", ); }); - await user.click(screen.getByLabelText("Reviewer Model")); - await user.click(screen.getByText("GPT-4o")); + await user.click(screen.getByLabelText("Executor Model")); + await user.selectOptions(await screen.findByTestId("custom-model-dropdown-credential-instance"), "anthropic-secondary"); await waitFor(() => { expect(mockUpdateTask).toHaveBeenNthCalledWith( 2, "FN-001", + { + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + credentialInstanceId: "anthropic-secondary", + }, + "project-alpha", + ); + expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({ + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + credentialInstanceId: "anthropic-secondary", + })); + }); + + await user.keyboard("{Escape}"); + await user.click(screen.getByLabelText("Reviewer Model")); + await user.click(screen.getByText("GPT-4o")); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenNthCalledWith( + 3, + "FN-001", { validatorModelProvider: "openai", validatorModelId: "gpt-4o", + validatorCredentialInstanceId: null, }, "project-alpha", ); expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({ validatorModelProvider: "openai", validatorModelId: "gpt-4o", + validatorCredentialInstanceId: null, })); expect(addToast).not.toHaveBeenCalledWith(expect.any(String), "error"); }); @@ -1892,6 +1927,9 @@ describe("TaskDetailModal", () => { ], favoriteProviders: [], favoriteModels: [], + providerInstances: { + anthropic: { instances: [{ id: "anthropic-primary", isDefault: true }, { id: "anthropic-secondary", isDefault: false }] }, + }, }); mockUpdateTask.mockRejectedValueOnce(new Error("Task not found")); @@ -1904,6 +1942,7 @@ describe("TaskDetailModal", () => { title: "Scoped reviewer failure", validatorModelProvider: "anthropic", validatorModelId: "claude-haiku-5", + validatorCredentialInstanceId: "anthropic-primary", })} projectId="project-alpha" onClose={noop} @@ -1924,11 +1963,15 @@ describe("TaskDetailModal", () => { expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { validatorModelProvider: "openai", validatorModelId: "gpt-4o", + validatorCredentialInstanceId: null, }, "project-alpha"); expect(addToast).toHaveBeenCalledTimes(1); expect(addToast).toHaveBeenCalledWith("Task not found", "error"); expect(screen.getByLabelText("Reviewer Model")).toHaveTextContent("Claude Haiku 5"); }); + + await user.click(screen.getByLabelText("Reviewer Model")); + expect(await screen.findByTestId("custom-model-dropdown-credential-instance")).toHaveValue("anthropic-primary"); }); it("renders Save and Cancel in the modal footer, not inside the edit form body", () => {