Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { RuntimeClineProviderModel, RuntimeClineReasoningEffort } from "@/runtime/types";

interface MockRuntimeSettingsState {
providerId: string;
modelId: string;
reasoningEffort: RuntimeClineReasoningEffort | "";
providerModels: RuntimeClineProviderModel[];
selectedModelSupportsReasoningEffort: boolean;
isLoadingProviderModels: boolean;
hasUnsavedChanges: boolean;
setModelId: ReturnType<typeof vi.fn>;
setReasoningEffort: ReturnType<typeof vi.fn>;
saveProviderSettings: ReturnType<typeof vi.fn>;
}

const runtimeSettingsStateRef = vi.hoisted(() => ({
current: null as MockRuntimeSettingsState | null,
}));

vi.mock("@/hooks/use-runtime-settings-cline-controller", () => ({
useRuntimeSettingsClineController: () => {
if (!runtimeSettingsStateRef.current) {
throw new Error("Expected runtime settings mock state.");
}
return runtimeSettingsStateRef.current;
},
}));

vi.mock("@/hooks/use-cline-chat-panel-controller", () => ({
useClineChatPanelController: () => ({
draft: "",
setDraft: vi.fn(),
messages: [],
error: null,
isSending: false,
canSend: true,
canCancel: false,
showReviewActions: false,
showAgentProgressIndicator: false,
showActionFooter: false,
showCancelAutomaticAction: false,
handleSendText: vi.fn(),
handleSendDraft: vi.fn(async () => true),
handleCancelTurn: vi.fn(),
}),
}));

vi.mock("@/components/detail-panels/cline-chat-composer", () => ({
ClineChatComposer: ({ onSelectModel }: { onSelectModel: (modelId: string) => void }) => (
<div>
<button type="button" data-testid="select-model" onClick={() => onSelectModel("anthropic/claude-opus-4.6")}>
Select model
</button>
</div>
),
}));

describe("ClineAgentChatPanel model persistence", () => {
let container: HTMLDivElement;
let root: Root;
let previousActEnvironment: boolean | undefined;

beforeEach(() => {
runtimeSettingsStateRef.current = {
providerId: "anthropic",
modelId: "anthropic/claude-sonnet-4.6",
reasoningEffort: "",
providerModels: [],
selectedModelSupportsReasoningEffort: false,
isLoadingProviderModels: false,
hasUnsavedChanges: false,
setModelId: vi.fn(),
setReasoningEffort: vi.fn(),
saveProviderSettings: vi.fn(async () => ({ ok: true })),
};
previousActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
vi.restoreAllMocks();
act(() => {
root.unmount();
});
container.remove();
if (previousActEnvironment === undefined) {
delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
} else {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
previousActEnvironment;
}
});

it("persists detail picker changes to task settings even when the task was inheriting defaults", async () => {
const onTaskClineSettingsChanged = vi.fn();
const { ClineAgentChatPanel } = await import("@/components/detail-panels/cline-agent-chat-panel");

await act(async () => {
root.render(
<ClineAgentChatPanel
taskId="task-1"
summary={null}
workspaceId="workspace-1"
onTaskClineSettingsChanged={onTaskClineSettingsChanged}
/>,
);
await Promise.resolve();
});

const selectModelButton = container.querySelector('[data-testid="select-model"]');
expect(selectModelButton).toBeInstanceOf(HTMLButtonElement);
if (!(selectModelButton instanceof HTMLButtonElement)) {
throw new Error("Expected model selection button.");
}

await act(async () => {
selectModelButton.click();
await Promise.resolve();
});

expect(runtimeSettingsStateRef.current?.setModelId).toHaveBeenCalledWith("anthropic/claude-opus-4.6");
expect(onTaskClineSettingsChanged).toHaveBeenCalledWith({
providerId: "anthropic",
modelId: "anthropic/claude-opus-4.6",
reasoningEffort: "",
});
expect(runtimeSettingsStateRef.current?.saveProviderSettings).not.toHaveBeenCalled();
});

it("falls back to saving workspace settings when no task override callback exists", async () => {
const { ClineAgentChatPanel } = await import("@/components/detail-panels/cline-agent-chat-panel");

await act(async () => {
root.render(<ClineAgentChatPanel taskId="task-1" summary={null} workspaceId="workspace-1" />);
await Promise.resolve();
});

const selectModelButton = container.querySelector('[data-testid="select-model"]');
expect(selectModelButton).toBeInstanceOf(HTMLButtonElement);
if (!(selectModelButton instanceof HTMLButtonElement)) {
throw new Error("Expected model selection button.");
}

await act(async () => {
selectModelButton.click();
await Promise.resolve();
});

expect(runtimeSettingsStateRef.current?.saveProviderSettings).toHaveBeenCalledWith({
modelId: "anthropic/claude-opus-4.6",
reasoningEffort: null,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,9 @@ export const ClineAgentChatPanel = React.forwardRef<ClineAgentChatPanelHandle, C
overrides && "reasoningEffort" in overrides
? overrides.reasoningEffort || ""
: clineSettings.reasoningEffort;
if (taskHasExplicitClineSettings) {
const shouldPersistTaskClineSettings =
taskHasExplicitClineSettings || onTaskClineSettingsChanged !== undefined;
if (shouldPersistTaskClineSettings) {
onTaskClineSettingsChanged?.({
providerId: clineSettings.providerId,
modelId: nextModelId,
Expand Down
134 changes: 134 additions & 0 deletions web-ui/src/hooks/use-runtime-settings-cline-controller.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { useRuntimeSettingsClineController } from "@/hooks/use-runtime-settings-cline-controller";
import type {
RuntimeClineProviderCatalogItem,
RuntimeClineProviderModel,
RuntimeClineReasoningEffort,
RuntimeConfigResponse,
Expand Down Expand Up @@ -600,6 +601,139 @@ describe("useRuntimeSettingsClineController", () => {
expect(requireSnapshot(latestSnapshot).hasUnsavedChanges).toBe(false);
});

it("uses the overridden provider default model when a task override omits modelId", async () => {
const config = createRuntimeConfigResponse({
providerId: "anthropic",
modelId: "anthropic/claude-opus-4.7",
});
let latestSnapshot: HookSnapshot | null = null;
fetchClineProviderCatalogMock.mockResolvedValue([
{
id: "anthropic",
name: "Anthropic",
oauthSupported: false,
enabled: true,
defaultModelId: "anthropic/claude-opus-4.7",
baseUrl: null,
},
{
id: "groq",
name: "Groq",
oauthSupported: false,
enabled: true,
defaultModelId: "groq/llama-4-maverick",
baseUrl: null,
},
]);
fetchClineProviderModelsMock.mockResolvedValue([
{
id: "groq/llama-4-maverick",
name: "Llama 4 Maverick",
contextWindow: null,
maxOutputTokens: null,
supportsReasoningEffort: false,
},
]);

await act(async () => {
root.render(
<HookHarness
open={true}
workspaceId="workspace-1"
selectedAgentId="cline"
config={config}
taskClineSettings={{
providerId: "groq",
}}
onSnapshot={(snapshot) => {
latestSnapshot = snapshot;
}}
/>,
);
await flushAsyncWork();
});

await act(async () => {
await flushAsyncWork();
});

expect(requireSnapshot(latestSnapshot).providerId).toBe("groq");
expect(requireSnapshot(latestSnapshot).modelId).toBe("groq/llama-4-maverick");
expect(requireSnapshot(latestSnapshot).hasUnsavedChanges).toBe(false);
});

it("does not transiently mark provider-default task overrides dirty while the provider catalog loads", async () => {
const config = createRuntimeConfigResponse({
providerId: "anthropic",
modelId: "anthropic/claude-opus-4.7",
});
const catalogDeferred = createDeferred<RuntimeClineProviderCatalogItem[]>();
const snapshots: HookSnapshot[] = [];
fetchClineProviderCatalogMock.mockReturnValue(catalogDeferred.promise);
fetchClineProviderModelsMock.mockResolvedValue([
{
id: "groq/llama-4-maverick",
name: "Llama 4 Maverick",
contextWindow: null,
maxOutputTokens: null,
supportsReasoningEffort: false,
},
]);

await act(async () => {
root.render(
<HookHarness
open={true}
workspaceId="workspace-1"
selectedAgentId="cline"
config={config}
taskClineSettings={{
providerId: "groq",
}}
onSnapshot={(snapshot) => {
snapshots.push(snapshot);
}}
/>,
);
await flushAsyncWork();
});

expect(snapshots.at(-1)?.providerId).toBe("groq");
expect(snapshots.at(-1)?.modelId).toBe("");
expect(snapshots.at(-1)?.hasUnsavedChanges).toBe(false);

await act(async () => {
catalogDeferred.resolve([
{
id: "anthropic",
name: "Anthropic",
oauthSupported: false,
enabled: true,
defaultModelId: "anthropic/claude-opus-4.7",
baseUrl: null,
},
{
id: "groq",
name: "Groq",
oauthSupported: false,
enabled: true,
defaultModelId: "groq/llama-4-maverick",
baseUrl: null,
},
]);
await flushAsyncWork();
await flushAsyncWork();
});

expect(
snapshots.some(
(snapshot) => snapshot.providerId === "groq" && snapshot.modelId === "" && snapshot.hasUnsavedChanges,
),
).toBe(false);
expect(snapshots.at(-1)?.modelId).toBe("groq/llama-4-maverick");
expect(snapshots.at(-1)?.hasUnsavedChanges).toBe(false);
});

it("treats an explicit task-level default reasoning override as the clean baseline", async () => {
const config = createRuntimeConfigResponse({
providerId: "openrouter",
Expand Down
Loading