chore: resolve merge conflicts
This commit is contained in:
@@ -419,6 +419,89 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Planning/Validator Model Settings ────────────────────────────
|
||||
|
||||
describe("planning/validator model settings", () => {
|
||||
it("saves and restores planning model settings via updateSettings", async () => {
|
||||
await store.updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("saves and restores validator model settings via updateSettings", async () => {
|
||||
await store.updateSettings({
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.validatorProvider).toBe("openai");
|
||||
expect(settings.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("saves and restores both planning and validator model settings via updateSettings", async () => {
|
||||
await store.updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.validatorProvider).toBe("openai");
|
||||
expect(settings.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("clears planning model settings when set to undefined", async () => {
|
||||
await store.updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
await store.updateSettings({
|
||||
planningProvider: undefined,
|
||||
planningModelId: undefined,
|
||||
});
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears validator model settings when set to undefined", async () => {
|
||||
await store.updateSettings({
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
await store.updateSettings({
|
||||
validatorProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
});
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
expect(settings.validatorModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists planning/validator settings in project config", async () => {
|
||||
await store.updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-opus-4",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4-turbo",
|
||||
});
|
||||
|
||||
// Verify the settings are in the project config file
|
||||
const configRaw = await readFile(join(rootDir, ".fusion", "config.json"), "utf-8");
|
||||
const config = JSON.parse(configRaw);
|
||||
expect(config.settings.planningProvider).toBe("anthropic");
|
||||
expect(config.settings.planningModelId).toBe("claude-opus-4");
|
||||
expect(config.settings.validatorProvider).toBe("openai");
|
||||
expect(config.settings.validatorModelId).toBe("gpt-4-turbo");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Global/Project Settings Merging ─────────────────────────────
|
||||
|
||||
describe("global/project settings merging", () => {
|
||||
|
||||
@@ -23,8 +23,10 @@ import { applyPresetToSelection, generatePresetId, validatePresetId } from "../u
|
||||
*
|
||||
* Sections:
|
||||
* - general: Task prefix configuration (project)
|
||||
* - model: Default AI model selection (global)
|
||||
* - default-model: Default AI model selection (global)
|
||||
* - execution-model: Planning and validator model selection (project)
|
||||
* - model-presets: Reusable model presets (project)
|
||||
* - ai-summarization: Auto-summarization settings (project)
|
||||
* - appearance: Theme and color settings (global)
|
||||
* - scheduling: Concurrency, poll interval, file overlap serialization (project)
|
||||
* - worktrees: Worktree limits, init commands, recycling (project)
|
||||
@@ -35,7 +37,8 @@ import { applyPresetToSelection, generatePresetId, validatePresetId } from "../u
|
||||
*/
|
||||
const SETTINGS_SECTIONS = [
|
||||
{ id: "general", label: "General", scope: "project" as const },
|
||||
{ id: "model", label: "Model", scope: "global" as const },
|
||||
{ id: "default-model", label: "Default Model", scope: "global" as const },
|
||||
{ id: "execution-model", label: "Execution Model", scope: "project" as const },
|
||||
{ id: "model-presets", label: "Model Presets", scope: "project" as const },
|
||||
{ id: "ai-summarization", label: "AI Summarization", scope: "project" as const },
|
||||
{ id: "appearance", label: "Appearance", scope: "global" as const },
|
||||
@@ -140,7 +143,7 @@ export function SettingsModal({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === "model") {
|
||||
if (activeSection === "default-model" || activeSection === "execution-model") {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
@@ -360,37 +363,40 @@ export function SettingsModal({
|
||||
taskPrefix: form.taskPrefix?.trim() || undefined,
|
||||
};
|
||||
|
||||
// Save only the scope matching the currently active section.
|
||||
// This prevents stale values from one scope being accidentally
|
||||
// overwritten when the user only changed fields in the other scope.
|
||||
if (activeSectionScope === "global") {
|
||||
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
|
||||
const globalPatch: Partial<GlobalSettings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (globalKeySet.has(key)) {
|
||||
(globalPatch as any)[key] = value;
|
||||
}
|
||||
// Always save both global and project settings.
|
||||
// The backend filters each appropriately (updateSettings ignores global keys,
|
||||
// updateGlobalSettings ignores project keys). This ensures fields in sections
|
||||
// are persisted correctly based on their scope.
|
||||
|
||||
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
|
||||
const globalPatch: Partial<GlobalSettings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (globalKeySet.has(key)) {
|
||||
(globalPatch as any)[key] = value;
|
||||
}
|
||||
await updateGlobalSettings(globalPatch);
|
||||
} else if (activeSectionScope === "project") {
|
||||
const projectKeySet = new Set<string>(PROJECT_SETTINGS_KEYS as readonly string[]);
|
||||
const projectPatch: Partial<Settings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "githubTokenConfigured") continue; // server-only field
|
||||
if (projectKeySet.has(key)) {
|
||||
(projectPatch as any)[key] = value;
|
||||
}
|
||||
}
|
||||
await updateSettings(projectPatch);
|
||||
}
|
||||
// Authentication section (scope: undefined) doesn't use the save button
|
||||
|
||||
const projectKeySet = new Set<string>(PROJECT_SETTINGS_KEYS as readonly string[]);
|
||||
const projectPatch: Partial<Settings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "githubTokenConfigured") continue; // server-only field
|
||||
if (projectKeySet.has(key)) {
|
||||
(projectPatch as any)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Save both scopes in parallel if they have changes
|
||||
await Promise.all([
|
||||
Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(),
|
||||
Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch) : Promise.resolve(),
|
||||
]);
|
||||
|
||||
addToast("Settings saved", "success");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [form, prefixError, presetDraft, activeSectionScope, onClose, addToast]);
|
||||
}, [form, prefixError, presetDraft, onClose, addToast]);
|
||||
|
||||
const savePresetDraft = () => {
|
||||
if (!presetDraft) return;
|
||||
@@ -512,10 +518,76 @@ export function SettingsModal({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "model": {
|
||||
case "default-model": {
|
||||
const selectedValue = form.defaultProvider && form.defaultModelId
|
||||
? `${form.defaultProvider}/${form.defaultModelId}`
|
||||
: "";
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Default Model</h4>
|
||||
{modelsLoading ? (
|
||||
<div className="settings-empty-state">Loading available models…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
<div className="settings-empty-state settings-muted">
|
||||
No models available. Configure authentication first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultModel">Default Model</label>
|
||||
<CustomModelDropdown
|
||||
id="defaultModel"
|
||||
label="Default Model"
|
||||
models={availableModels}
|
||||
value={selectedValue}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined }));
|
||||
} else {
|
||||
const slashIdx = val.indexOf("/");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
defaultProvider: val.slice(0, slashIdx),
|
||||
defaultModelId: val.slice(slashIdx + 1),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
placeholder="Use default"
|
||||
/>
|
||||
<small>Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically.</small>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const selectedModel = availableModels.find(
|
||||
(m) => m.provider === form.defaultProvider && m.id === form.defaultModelId,
|
||||
);
|
||||
if (selectedModel && !selectedModel.reasoning) return null;
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultThinkingLevel">Thinking Effort</label>
|
||||
<select
|
||||
id="defaultThinkingLevel"
|
||||
value={form.defaultThinkingLevel || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, defaultThinkingLevel: val || undefined } as any));
|
||||
}}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level.charAt(0).toUpperCase() + level.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.</small>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "execution-model": {
|
||||
const planningValue = form.planningProvider && form.planningModelId
|
||||
? `${form.planningProvider}/${form.planningModelId}`
|
||||
: "";
|
||||
@@ -525,7 +597,7 @@ export function SettingsModal({
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Model</h4>
|
||||
<h4 className="settings-section-heading">Execution Model</h4>
|
||||
{modelsLoading ? (
|
||||
<div className="settings-empty-state">Loading available models…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
@@ -534,31 +606,6 @@ export function SettingsModal({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultModel">Default Model</label>
|
||||
<CustomModelDropdown
|
||||
id="defaultModel"
|
||||
label="Default Model"
|
||||
models={availableModels}
|
||||
value={selectedValue}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined }));
|
||||
} else {
|
||||
const slashIdx = val.indexOf("/");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
defaultProvider: val.slice(0, slashIdx),
|
||||
defaultModelId: val.slice(slashIdx + 1),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
<small>Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="planningModel">Planning Model</label>
|
||||
<CustomModelDropdown
|
||||
@@ -611,33 +658,6 @@ export function SettingsModal({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(() => {
|
||||
const selectedModel = availableModels.find(
|
||||
(m) => m.provider === form.defaultProvider && m.id === form.defaultModelId,
|
||||
);
|
||||
if (selectedModel && !selectedModel.reasoning) return null;
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultThinkingLevel">Thinking Effort</label>
|
||||
<select
|
||||
id="defaultThinkingLevel"
|
||||
value={form.defaultThinkingLevel || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, defaultThinkingLevel: val || undefined } as any));
|
||||
}}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level.charAt(0).toUpperCase() + level.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.</small>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ describe("SettingsModal", () => {
|
||||
expect(payload.pollIntervalMs).toBe(15000);
|
||||
});
|
||||
|
||||
it("saving project settings does not update global settings endpoint", async () => {
|
||||
it("saving in General section updates project settings with task prefix", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
@@ -381,30 +381,68 @@ describe("SettingsModal", () => {
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Global settings should NOT be called when in a project section
|
||||
expect(updateGlobalSettings).not.toHaveBeenCalled();
|
||||
// Verify taskPrefix is saved as project setting
|
||||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(projectPayload.taskPrefix).toBe("TEST");
|
||||
});
|
||||
|
||||
it("saving in Model section only updates global settings", async () => {
|
||||
it("saving in Default Model section updates global settings with default model", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
// Model section is global-scoped
|
||||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||
// Select a model
|
||||
const trigger = screen.getByLabelText("Default Model");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
// Project settings should NOT be updated when in a global section
|
||||
expect(updateSettings).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
// Verify default model settings are saved as global settings
|
||||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(globalPayload.defaultProvider).toBe("anthropic");
|
||||
expect(globalPayload.defaultModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("shows Model in sidebar", async () => {
|
||||
it("saving in Execution Model section updates project settings with planning and validator models", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getAllByText("Model").length).toBeGreaterThanOrEqual(1);
|
||||
fireEvent.click(screen.getByText("Execution Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Select planning model
|
||||
const planningTrigger = screen.getByLabelText("Planning Model");
|
||||
await user.click(planningTrigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
// Select validator model
|
||||
const validatorTrigger = screen.getByLabelText("Validator Model");
|
||||
await user.click(validatorTrigger);
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
// Verify planning and validator settings are saved as project settings
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(projectPayload.planningProvider).toBe("anthropic");
|
||||
expect(projectPayload.planningModelId).toBe("claude-sonnet-4-5");
|
||||
expect(projectPayload.validatorProvider).toBe("openai");
|
||||
expect(projectPayload.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("shows Default Model and Execution Model in sidebar", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getAllByText("Default Model").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Execution Model").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("supports creating and saving a model preset", async () => {
|
||||
@@ -461,7 +499,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Dropdown trigger should be present
|
||||
@@ -486,7 +524,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open dropdown and select a model
|
||||
@@ -514,7 +552,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open dropdown and select "Use default"
|
||||
@@ -545,7 +583,77 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByText("No models available. Configure authentication first.")).toBeTruthy();
|
||||
});
|
||||
|
||||
// --- Execution Model section tests (project-scoped) ---
|
||||
|
||||
it("shows Execution Model section with planning and validator model dropdowns", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Execution Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Both dropdowns should be present
|
||||
expect(screen.getByLabelText("Planning Model")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Validator Model")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("selecting a planning model updates form with provider and model ID", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Execution Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open planning model dropdown and select a model
|
||||
const trigger = screen.getByLabelText("Planning Model");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
// planningProvider and planningModelId are project settings, so they go through updateSettings
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.planningProvider).toBe("anthropic");
|
||||
expect(payload.planningModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("selecting a validator model updates form with provider and model ID", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Execution Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open validator model dropdown and select a model
|
||||
const trigger = screen.getByLabelText("Validator Model");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
// validatorProvider and validatorModelId are project settings, so they go through updateSettings
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.validatorProvider).toBe("openai");
|
||||
expect(payload.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("shows empty state in Execution Model section when no models available", async () => {
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Execution Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByText("No models available. Configure authentication first.")).toBeTruthy();
|
||||
@@ -660,7 +768,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// CustomModelDropdown renders as a button trigger, not a select element
|
||||
@@ -692,11 +800,11 @@ describe("SettingsModal", () => {
|
||||
expect(elementsWithStyle.length).toBe(1);
|
||||
});
|
||||
|
||||
it("shows Thinking Effort dropdown with correct options in Model section", async () => {
|
||||
it("shows Thinking Effort dropdown with correct options in Default Model section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const select = screen.getByLabelText("Thinking Effort") as HTMLSelectElement;
|
||||
@@ -710,7 +818,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const select = screen.getByLabelText("Thinking Effort") as HTMLSelectElement;
|
||||
@@ -734,7 +842,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByLabelText("Thinking Effort")).toBeNull();
|
||||
@@ -852,20 +960,21 @@ describe("SettingsModal", () => {
|
||||
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has .settings-sidebar with 12 .settings-nav-item buttons for all sections", async () => {
|
||||
it("has .settings-sidebar with 13 .settings-nav-item buttons for all sections", async () => {
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const sidebar = container.querySelector(".settings-sidebar");
|
||||
expect(sidebar).toBeTruthy();
|
||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||
expect(navItems.length).toBe(12);
|
||||
expect(navItems.length).toBe(13);
|
||||
|
||||
// Labels include scope emoji indicators (🌐 for global, 📁 for project)
|
||||
const labels = Array.from(navItems).map((el) => el.textContent);
|
||||
expect(labels).toEqual([
|
||||
"📁General",
|
||||
"🌐Model",
|
||||
"🌐Default Model",
|
||||
"📁Execution Model",
|
||||
"📁Model Presets",
|
||||
"📁AI Summarization",
|
||||
"🌐Appearance",
|
||||
@@ -1130,12 +1239,12 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
// Model filter tests with CustomModelDropdown
|
||||
it("renders filter input in Model section dropdown", async () => {
|
||||
it("renders filter input in Default Model section dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open dropdown to access filter input
|
||||
@@ -1146,12 +1255,12 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("filters default model options in Model section", async () => {
|
||||
it("filters default model options in Default Model section", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open dropdown
|
||||
@@ -1170,12 +1279,12 @@ describe("SettingsModal", () => {
|
||||
expect(screen.queryByText("GPT-4o")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clear button resets filter in Model section", async () => {
|
||||
it("clear button resets filter in Default Model section", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open dropdown
|
||||
@@ -1202,12 +1311,12 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByText("Claude Sonnet 4.5")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state in Model section when filter matches nothing", async () => {
|
||||
it("shows empty state in Default Model section when filter matches nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open dropdown
|
||||
@@ -1421,10 +1530,15 @@ describe("SettingsModal", () => {
|
||||
expect(container.querySelector(".settings-scope-project")).toBeTruthy();
|
||||
expect(container.querySelector(".settings-scope-global")).toBeNull();
|
||||
|
||||
// Switch to Model → should show global banner
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
// Switch to Default Model → should show global banner
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
expect(container.querySelector(".settings-scope-global")).toBeTruthy();
|
||||
expect(container.querySelector(".settings-scope-project")).toBeNull();
|
||||
|
||||
// Switch to Execution Model → should show project banner
|
||||
fireEvent.click(screen.getByText("Execution Model"));
|
||||
expect(container.querySelector(".settings-scope-project")).toBeTruthy();
|
||||
expect(container.querySelector(".settings-scope-global")).toBeNull();
|
||||
});
|
||||
|
||||
// --- Settings save error handling tests ---
|
||||
@@ -1448,8 +1562,8 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Switch to Model section (global scope)
|
||||
fireEvent.click(screen.getByText("Model"));
|
||||
// Switch to Default Model section (global scope)
|
||||
fireEvent.click(screen.getByText("Default Model"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
@@ -93,6 +93,20 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
}, [refreshTasks]);
|
||||
|
||||
// Fetch initial tasks and recover when the tab becomes visible again.
|
||||
useEffect(() => {
|
||||
void refreshTasks();
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
void refreshTasks();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refreshTasks]);
|
||||
|
||||
// SSE live updates
|
||||
// Note: In multi-project mode, SSE receives all task events.
|
||||
// Tasks are filtered by ID match, so cross-project updates won't affect
|
||||
|
||||
@@ -5372,6 +5372,75 @@ describe("PUT /settings", () => {
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toContain("Write failed");
|
||||
});
|
||||
|
||||
it("updates planning and validator model settings via store.updateSettings", async () => {
|
||||
const updatedSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
};
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"PUT",
|
||||
"/api/settings",
|
||||
JSON.stringify({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists planning/validator settings and returns them via GET /settings", async () => {
|
||||
// First, update the settings
|
||||
const updatedSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-opus-4",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4-turbo",
|
||||
};
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||||
|
||||
const updateRes = await REQUEST(
|
||||
buildApp(),
|
||||
"PUT",
|
||||
"/api/settings",
|
||||
JSON.stringify({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-opus-4",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4-turbo",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
// Then, verify GET /settings returns the persisted values
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||||
const getRes = await GET(buildApp(), "/api/settings");
|
||||
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body.planningProvider).toBe("anthropic");
|
||||
expect(getRes.body.planningModelId).toBe("claude-opus-4");
|
||||
expect(getRes.body.validatorProvider).toBe("openai");
|
||||
expect(getRes.body.validatorModelId).toBe("gpt-4-turbo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /settings/global", () => {
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
calculatePace,
|
||||
_setSleepFn,
|
||||
_resetSleepFn,
|
||||
<<<<<<< ours
|
||||
_stripClaudeAnsi,
|
||||
_parseClaudePercentLine,
|
||||
_parseClaudeResetLine,
|
||||
_parseClaudeResetText,
|
||||
=======
|
||||
>>>>>>> theirs
|
||||
} from "./usage.js";
|
||||
|
||||
// Mock the https module
|
||||
@@ -401,6 +404,7 @@ describe("usage", () => {
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
});
|
||||
|
||||
<<<<<<< ours
|
||||
it("handles 403 auth error", async () => {
|
||||
setupClaudeMocks({
|
||||
credFileContent: {
|
||||
@@ -417,6 +421,104 @@ describe("usage", () => {
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from('{"error": "forbidden"}'));
|
||||
if (event === "end") handler();
|
||||
=======
|
||||
it("does not send anthropic-beta header in requests", async () => {
|
||||
const mockResponse = {
|
||||
five_hour: { utilization: 10.0 },
|
||||
};
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
capturedHeaders = options.headers || {};
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
// Verify no anthropic-beta header is sent
|
||||
expect(capturedHeaders).not.toHaveProperty("anthropic-beta");
|
||||
});
|
||||
|
||||
it("retries on 429 and succeeds after transient rate limit", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
const mockResponse = {
|
||||
five_hour: {
|
||||
utilization: 20.0,
|
||||
resets_at: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
callCount++;
|
||||
const is429 = callCount <= 2; // First 2 calls return 429, third succeeds
|
||||
const mockRes = {
|
||||
statusCode: is429 ? 429 : 200,
|
||||
headers: is429 ? { "retry-after": "1" } : {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
const body = is429
|
||||
? '{"error":"rate_limited"}'
|
||||
: JSON.stringify(mockResponse);
|
||||
handler(Buffer.from(body));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
>>>>>>> theirs
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
@@ -426,6 +528,7 @@ describe("usage", () => {
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
<<<<<<< ours
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
});
|
||||
@@ -446,6 +549,53 @@ describe("usage", () => {
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from("not valid json {{{"));
|
||||
if (event === "end") handler();
|
||||
=======
|
||||
expect(claude.status).toBe("ok");
|
||||
expect(claude.windows).toHaveLength(1);
|
||||
expect(claude.windows[0].percentUsed).toBe(20);
|
||||
|
||||
// Verify sleep was called for retries (2 retry sleeps)
|
||||
expect(noopSleep).toHaveBeenCalledTimes(2);
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("reports rate limited after all retries exhausted on 429", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
// Always return 429
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 429,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error":"rate_limited"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
>>>>>>> theirs
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
@@ -456,6 +606,7 @@ describe("usage", () => {
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
<<<<<<< ours
|
||||
expect(claude.error).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -488,10 +639,162 @@ describe("usage", () => {
|
||||
});
|
||||
|
||||
setupClaudeApiResponse({ five_hour: { utilization: 10 } });
|
||||
=======
|
||||
expect(claude.error).toBe("Rate limited — try again later");
|
||||
|
||||
// Verify retries happened (2 sleeps for 3 attempts)
|
||||
expect(noopSleep).toHaveBeenCalledTimes(2);
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("uses exponential backoff delays when retry-after header is absent", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
// Always return 429 without retry-after
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 429,
|
||||
headers: {}, // No retry-after header
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error":"rate_limited"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
// Exponential backoff: 1000ms * 2^0 = 1000, 1000ms * 2^1 = 2000
|
||||
expect(noopSleep).toHaveBeenCalledTimes(2);
|
||||
expect(noopSleep).toHaveBeenNthCalledWith(1, 1000);
|
||||
expect(noopSleep).toHaveBeenNthCalledWith(2, 2000);
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("respects retry-after header value for delay", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
// 429 with retry-after: 5 seconds
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 429,
|
||||
headers: { "retry-after": "5" },
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error":"rate_limited"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
// Should use retry-after value (5s = 5000ms) for both retries
|
||||
expect(noopSleep).toHaveBeenCalledTimes(2);
|
||||
expect(noopSleep).toHaveBeenNthCalledWith(1, 5000);
|
||||
expect(noopSleep).toHaveBeenNthCalledWith(2, 5000);
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("does not retry on 401 auth errors", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "expired-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 401,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "unauthorized"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
>>>>>>> theirs
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
<<<<<<< ours
|
||||
expect(claude.plan).toBe("Team");
|
||||
});
|
||||
|
||||
@@ -507,11 +810,69 @@ describe("usage", () => {
|
||||
});
|
||||
|
||||
setupClaudeApiResponse({ five_hour: { utilization: 10 } });
|
||||
=======
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
// No retries should happen for auth errors
|
||||
expect(noopSleep).not.toHaveBeenCalled();
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("does not retry on 403 auth errors", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "forbidden-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 403,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "forbidden"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
>>>>>>> theirs
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
<<<<<<< ours
|
||||
expect(claude.plan).toBe("Pro");
|
||||
=======
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
// No retries should happen for auth errors
|
||||
expect(noopSleep).not.toHaveBeenCalled();
|
||||
|
||||
_resetSleepFn();
|
||||
>>>>>>> theirs
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user