Enable Quick Add and task editing to select merger models and thinking levels. - Persist merger model and thinking overrides through task APIs, storage, and PostgreSQL migrations. - Add merger-lane selection controls to Quick Add and model settings interfaces. - Apply task merger settings to merger and PR fallback sessions, with regression coverage. - Document the merger lane and include a release changeset. Files changed: .changeset/fn-8126-quick-add-merger-lane.md | 7 ++ docs/dashboard-guide.md | 2 + docs/settings-reference.md | 7 +- .../core/src/__tests__/model-resolution.test.ts | 9 ++ packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/model-resolution.ts | 20 ++++ .../core/src/postgres/migrations/0000_initial.sql | 3 + .../migrations/0017_task_merger_model_lane.sql | 4 + packages/core/src/postgres/schema-applier.ts | 14 ++- packages/core/src/postgres/schema/project.ts | 3 + packages/core/src/store.ts | 2 +- .../core/src/task-store/archive-lifecycle-2.ts | 6 ++ packages/core/src/task-store/persistence.ts | 6 ++ packages/core/src/task-store/remaining-ops-2.ts | 4 +- packages/core/src/task-store/remaining-ops-6.ts | 2 +- packages/core/src/task-store/serialization.ts | 6 ++ packages/core/src/task-store/task-creation.ts | 6 ++ packages/core/src/task-store/task-row-mappers.ts | 4 +- packages/core/src/task-store/task-update.ts | 6 ++ packages/core/src/types.ts | 18 ++++ packages/dashboard/app/api/tasks.ts | 13 +++ .../dashboard/app/components/InlineCreateCard.tsx | 33 ++++++- .../app/components/ModelSelectionModal.tsx | 29 ++++++ .../dashboard/app/components/ModelSelectorTab.tsx | 101 +++++++++++++++++++-- .../dashboard/app/components/QuickEntryBox.tsx | 44 +++++++-- .../__tests__/ModelSelectionModal.test.tsx | 20 ++++ .../components/__tests__/ModelSelectorTab.test.tsx | 37 +++++++- .../src/routes/register-task-workflow-routes.ts | 27 +++++- .../src/__tests__/agent-session-helpers.test.ts | 8 ++ packages/engine/src/agent-session-helpers.ts | 16 +++- packages/engine/src/merger-ai.ts | 14 +-- packages/engine/src/merger.ts | 35 ++++--- packages/engine/src/pr-response-run-ops.ts | 7 +- 34 files changed, 451 insertions(+), 64 deletions(-) Fusion-Task-Id: FN-8126 Fusion-Task-Lineage: 3fc81801-6d77-4e11-9cf0-3af37313930e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { TaskDetail } from "@fusion/core";
|
|
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
|
import { ModelSelectorTab } from "../ModelSelectorTab";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchModels: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
updateGlobalSettings: vi.fn(),
|
|
}));
|
|
|
|
const { fetchModels, updateTask } = await import("../../api");
|
|
const mockFetchModels = vi.mocked(fetchModels);
|
|
const mockUpdateTask = vi.mocked(updateTask);
|
|
|
|
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
|
return {
|
|
id: "FN-7398",
|
|
title: "Anthropic model selector",
|
|
description: "Verify Claude CLI model selection",
|
|
column: "todo",
|
|
steps: [],
|
|
dependencies: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
} as unknown as TaskDetail;
|
|
}
|
|
|
|
async function selectDropdownOption(user: ReturnType<typeof userEvent.setup>, label: string, option: string) {
|
|
await user.click(screen.getByLabelText(label));
|
|
const listbox = await screen.findByRole("listbox");
|
|
await user.click(within(listbox).getByText(option));
|
|
}
|
|
|
|
describe("ModelSelectorTab", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.removeItem(SWR_CACHE_KEYS.MODELS);
|
|
mockFetchModels.mockResolvedValue({
|
|
models: [
|
|
{ provider: "pi-claude-cli", id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)", reasoning: true, contextWindow: 1_000_000 },
|
|
],
|
|
favoriteProviders: [],
|
|
favoriteModels: [],
|
|
});
|
|
});
|
|
|
|
it("renders and selects Anthropic Claude CLI rows from the shared model catalog", async () => {
|
|
const user = userEvent.setup();
|
|
const addToast = vi.fn();
|
|
const onTaskUpdated = vi.fn();
|
|
const task = makeTask();
|
|
mockUpdateTask.mockResolvedValueOnce({
|
|
...task,
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
});
|
|
|
|
render(
|
|
<ModelSelectorTab
|
|
task={task}
|
|
addToast={addToast}
|
|
onTaskUpdated={onTaskUpdated}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/No models available/i)).not.toBeInTheDocument();
|
|
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByLabelText("Executor Model"));
|
|
|
|
const listbox = await screen.findByRole("listbox");
|
|
expect(within(listbox).getByText("pi-claude-cli")).toBeInTheDocument();
|
|
await user.click(within(listbox).getByText("Claude Sonnet 5 (CLI)"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", {
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
}, undefined);
|
|
expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
}));
|
|
});
|
|
});
|
|
|
|
it("passes the scoped project id for executor, reviewer, planning, and thinking saves", async () => {
|
|
const user = userEvent.setup();
|
|
const addToast = vi.fn();
|
|
const onTaskUpdated = vi.fn();
|
|
const task = makeTask({
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-haiku-5",
|
|
validatorModelProvider: "pi-claude-cli",
|
|
validatorModelId: "claude-haiku-5",
|
|
planningModelProvider: "pi-claude-cli",
|
|
planningModelId: "claude-haiku-5",
|
|
mergerModelProvider: "pi-claude-cli",
|
|
mergerModelId: "claude-haiku-5",
|
|
thinkingLevel: "minimal",
|
|
});
|
|
|
|
mockFetchModels.mockResolvedValue({
|
|
models: [
|
|
{ provider: "pi-claude-cli", id: "claude-haiku-5", name: "Claude Haiku 5 (CLI)", reasoning: true, contextWindow: 200_000 },
|
|
{ provider: "pi-claude-cli", id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)", reasoning: true, contextWindow: 1_000_000 },
|
|
],
|
|
favoriteProviders: [],
|
|
favoriteModels: [],
|
|
});
|
|
mockUpdateTask
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
validatorModelProvider: "pi-claude-cli",
|
|
validatorModelId: "claude-sonnet-5",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
planningModelProvider: "pi-claude-cli",
|
|
planningModelId: "claude-sonnet-5",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
mergerModelProvider: "pi-claude-cli",
|
|
mergerModelId: "claude-sonnet-5",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
thinkingLevel: "high",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
validatorThinkingLevel: "high",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
planningThinkingLevel: "high",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
mergerThinkingLevel: "high",
|
|
});
|
|
|
|
render(
|
|
<ModelSelectorTab
|
|
task={task}
|
|
addToast={addToast}
|
|
onTaskUpdated={onTaskUpdated}
|
|
projectId="project-alpha"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument());
|
|
|
|
await selectDropdownOption(user, "Executor Model", "Claude Sonnet 5 (CLI)");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-7398", {
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await selectDropdownOption(user, "Reviewer Model", "Claude Sonnet 5 (CLI)");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-7398", {
|
|
validatorModelProvider: "pi-claude-cli",
|
|
validatorModelId: "claude-sonnet-5",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await selectDropdownOption(user, "Planning Model", "Claude Sonnet 5 (CLI)");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(3, "FN-7398", {
|
|
planningModelProvider: "pi-claude-cli",
|
|
planningModelId: "claude-sonnet-5",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await selectDropdownOption(user, "Merger Model", "Claude Sonnet 5 (CLI)");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(4, "FN-7398", {
|
|
mergerModelProvider: "pi-claude-cli",
|
|
mergerModelId: "claude-sonnet-5",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await user.click(screen.getByRole("button", { name: /Executor Model/ }));
|
|
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(5, "FN-7398", {
|
|
thinkingLevel: "high",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await user.click(screen.getByRole("button", { name: /Reviewer Model/ }));
|
|
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(6, "FN-7398", {
|
|
validatorThinkingLevel: "high",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await user.click(screen.getByRole("button", { name: /Planning Model/ }));
|
|
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(7, "FN-7398", {
|
|
planningThinkingLevel: "high",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await user.click(screen.getByRole("button", { name: /Merger Model/ }));
|
|
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(8, "FN-7398", {
|
|
mergerThinkingLevel: "high",
|
|
}, "project-alpha");
|
|
});
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("set to"), "success");
|
|
});
|
|
|
|
it("clears model overrides with the scoped project id", async () => {
|
|
const user = userEvent.setup();
|
|
const task = makeTask({
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
});
|
|
mockUpdateTask.mockResolvedValueOnce({
|
|
...task,
|
|
modelProvider: null,
|
|
modelId: null,
|
|
});
|
|
|
|
render(
|
|
<ModelSelectorTab
|
|
task={task}
|
|
addToast={vi.fn()}
|
|
onTaskUpdated={vi.fn()}
|
|
projectId="project-alpha"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument());
|
|
await selectDropdownOption(user, "Executor Model", "Use default");
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", {
|
|
modelProvider: null,
|
|
modelId: null,
|
|
}, "project-alpha");
|
|
});
|
|
});
|
|
|
|
it("renders reviewer and planning thinking badges with default fallback and concrete overrides", async () => {
|
|
const task = makeTask({
|
|
thinkingLevel: "medium",
|
|
validatorThinkingLevel: "high",
|
|
planningThinkingLevel: undefined,
|
|
mergerThinkingLevel: "low",
|
|
});
|
|
|
|
render(
|
|
<ModelSelectorTab
|
|
task={task}
|
|
addToast={vi.fn()}
|
|
settings={{ defaultThinkingLevel: "low" } as any}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument());
|
|
|
|
const badges = screen.getAllByTestId("custom-model-dropdown-thinking-badge");
|
|
expect(badges).toHaveLength(4);
|
|
expect(badges[0]).toHaveTextContent("Medium");
|
|
expect(badges[1]).toHaveTextContent("High");
|
|
expect(badges[2]).toHaveTextContent("Default (low)");
|
|
expect(badges[3]).toHaveTextContent("Low");
|
|
});
|
|
|
|
it("updates from a cached empty catalog to populated Claude CLI rows without remounting", async () => {
|
|
localStorage.setItem(
|
|
SWR_CACHE_KEYS.MODELS,
|
|
JSON.stringify({
|
|
savedAt: Date.now(),
|
|
data: { models: [], favoriteProviders: [], favoriteModels: [] },
|
|
}),
|
|
);
|
|
|
|
render(<ModelSelectorTab task={makeTask()} addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText(/No models available/i)).toBeInTheDocument();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/No models available/i)).not.toBeInTheDocument();
|
|
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|