Fix provider refresh scope and self-healing
This commit is contained in:
@@ -161,6 +161,37 @@ describe("useAgentFormState", () => {
|
||||
},
|
||||
];
|
||||
|
||||
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{},
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: null,
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBeNull();
|
||||
expect(resolved.modeId).toBe("");
|
||||
expect(resolved.model).toBe("");
|
||||
expect(resolved.thinkingOptionId).toBe("");
|
||||
});
|
||||
|
||||
it("does not auto-select a model on fresh drafts without preferences", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
@@ -374,7 +405,7 @@ describe("useAgentFormState", () => {
|
||||
expect(resolved.thinkingOptionId).toBe("low");
|
||||
});
|
||||
|
||||
it("resolves provider only from allowed provider map", () => {
|
||||
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
@@ -399,7 +430,121 @@ describe("useAgentFormState", () => {
|
||||
claudeProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("claude");
|
||||
expect(resolved.provider).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
||||
const loadingEntries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: loadingEntries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||
});
|
||||
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{},
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: true,
|
||||
modeId: true,
|
||||
model: true,
|
||||
thinkingOptionId: true,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.3-codex",
|
||||
thinkingOptionId: "xhigh",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
resolvableProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
expect(resolved.modeId).toBe("full-access");
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
||||
const unavailableEntries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "unavailable",
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: unavailableEntries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||
});
|
||||
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{},
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: true,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.3-codex",
|
||||
thinkingOptionId: "xhigh",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
resolvableProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBeNull();
|
||||
expect(resolved.modeId).toBe("");
|
||||
expect(resolved.model).toBe("");
|
||||
expect(resolved.thinkingOptionId).toBe("");
|
||||
});
|
||||
|
||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||
// Internal form state
|
||||
interface FormState {
|
||||
serverId: string | null;
|
||||
provider: AgentProvider;
|
||||
provider: AgentProvider | null;
|
||||
modeId: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
@@ -68,7 +68,7 @@ export type UseAgentFormStateResult = {
|
||||
selectedServerId: string | null;
|
||||
setSelectedServerId: (value: string | null) => void;
|
||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||
selectedProvider: AgentProvider;
|
||||
selectedProvider: AgentProvider | null;
|
||||
setProviderFromUser: (provider: AgentProvider) => void;
|
||||
selectedMode: string;
|
||||
setModeFromUser: (modeId: string) => void;
|
||||
@@ -97,9 +97,6 @@ export type UseAgentFormStateResult = {
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER: AgentProvider = "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
|
||||
|
||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized) {
|
||||
@@ -177,8 +174,6 @@ function resolveFormState(
|
||||
): FormState {
|
||||
// Start with current state - we only update non-user-modified fields
|
||||
const result = { ...currentState };
|
||||
const fallbackProvider = allowedProviderMap.keys().next().value as AgentProvider | undefined;
|
||||
|
||||
// 1. Resolve provider first (other fields depend on it)
|
||||
if (!userModified.provider) {
|
||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||
@@ -188,22 +183,33 @@ function resolveFormState(
|
||||
allowedProviderMap.has(preferences.provider as AgentProvider)
|
||||
) {
|
||||
result.provider = preferences.provider as AgentProvider;
|
||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
||||
result.provider = fallbackProvider;
|
||||
} else if (
|
||||
result.provider &&
|
||||
allowedProviderMap.size > 0 &&
|
||||
!allowedProviderMap.has(result.provider)
|
||||
) {
|
||||
result.provider = null;
|
||||
}
|
||||
// else keep current (initialized to DEFAULT_PROVIDER)
|
||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
||||
result.provider = fallbackProvider;
|
||||
} else if (
|
||||
result.provider &&
|
||||
allowedProviderMap.size > 0 &&
|
||||
!allowedProviderMap.has(result.provider)
|
||||
) {
|
||||
result.provider = null;
|
||||
}
|
||||
|
||||
const providerDef = allowedProviderMap.get(result.provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[result.provider];
|
||||
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
||||
const providerPrefs = result.provider
|
||||
? preferences?.providerPreferences?.[result.provider]
|
||||
: undefined;
|
||||
|
||||
// 2. Resolve modeId (depends on provider)
|
||||
if (!userModified.modeId) {
|
||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||
|
||||
if (
|
||||
if (!result.provider) {
|
||||
result.modeId = "";
|
||||
} else if (
|
||||
typeof initialValues?.modeId === "string" &&
|
||||
initialValues.modeId.length > 0 &&
|
||||
validModeIds.includes(initialValues.modeId)
|
||||
@@ -223,7 +229,9 @@ function resolveFormState(
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||
|
||||
if (initialModel) {
|
||||
if (!result.provider) {
|
||||
result.model = "";
|
||||
} else if (initialModel) {
|
||||
if (!availableModels || isValidModel(initialModel)) {
|
||||
result.model = initialModel;
|
||||
} else {
|
||||
@@ -246,7 +254,9 @@ function resolveFormState(
|
||||
? initialValues.thinkingOptionId.trim()
|
||||
: "";
|
||||
|
||||
if (!userModified.thinkingOptionId) {
|
||||
if (!result.provider) {
|
||||
result.thinkingOptionId = "";
|
||||
} else if (!userModified.thinkingOptionId) {
|
||||
const effectiveModelId = result.model.trim();
|
||||
const preferredThinking = effectiveModelId
|
||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
||||
@@ -262,7 +272,7 @@ function resolveFormState(
|
||||
}
|
||||
|
||||
// Validate thinking option once model metadata is available.
|
||||
if (availableModels) {
|
||||
if (result.provider && availableModels) {
|
||||
result.thinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels,
|
||||
modelId: result.model,
|
||||
@@ -312,6 +322,37 @@ function combineInitialValues(
|
||||
return initialValues;
|
||||
}
|
||||
|
||||
const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]);
|
||||
const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
||||
|
||||
function buildProviderDefinitionMap(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
): Map<AgentProvider, AgentProviderDefinition> {
|
||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||
providerDefinitions.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
}
|
||||
|
||||
function buildProviderDefinitionMapForStatuses(args: {
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
||||
}): Map<AgentProvider, AgentProviderDefinition> {
|
||||
if (!args.snapshotEntries?.length) {
|
||||
return buildProviderDefinitionMap(args.providerDefinitions);
|
||||
}
|
||||
|
||||
const matchingProviders = new Set(
|
||||
args.snapshotEntries
|
||||
.filter((entry) => args.statuses.has(entry.status))
|
||||
.map((entry) => entry.provider),
|
||||
);
|
||||
|
||||
return buildProviderDefinitionMap(
|
||||
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
||||
const {
|
||||
initialServerId = null,
|
||||
@@ -335,8 +376,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
// Form state
|
||||
const [formState, setFormState] = useState<FormState>(() => ({
|
||||
serverId: initialServerId,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
|
||||
provider: null,
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
@@ -371,25 +412,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
|
||||
),
|
||||
() => buildProviderDefinitionMap(snapshotProviderDefinitions),
|
||||
[snapshotProviderDefinitions],
|
||||
);
|
||||
const snapshotResolvableProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries,
|
||||
providerDefinitions: snapshotProviderDefinitions,
|
||||
statuses: RESOLVABLE_PROVIDER_STATUSES,
|
||||
}),
|
||||
[snapshotEntries, snapshotProviderDefinitions],
|
||||
);
|
||||
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
||||
if (!snapshotEntries?.length) {
|
||||
return snapshotProviderDefinitionMap;
|
||||
}
|
||||
const readyProviders = new Set(
|
||||
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
|
||||
);
|
||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||
snapshotProviderDefinitions
|
||||
.filter((definition) => readyProviders.has(definition.id))
|
||||
.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
|
||||
return buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries,
|
||||
providerDefinitions: snapshotProviderDefinitions,
|
||||
statuses: SELECTABLE_PROVIDER_STATUSES,
|
||||
});
|
||||
}, [snapshotEntries, snapshotProviderDefinitions]);
|
||||
const snapshotAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (const entry of snapshotEntries ?? []) {
|
||||
@@ -398,13 +439,16 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
return map;
|
||||
}, [snapshotEntries]);
|
||||
const snapshotSelectedEntry = useMemo(
|
||||
() => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null,
|
||||
() =>
|
||||
formState.provider
|
||||
? ((snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null)
|
||||
: null,
|
||||
[formState.provider, snapshotEntries],
|
||||
);
|
||||
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
|
||||
const snapshotSelectedProviderModes =
|
||||
snapshotSelectedEntry?.modes ??
|
||||
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
||||
(formState.provider ? snapshotProviderDefinitionMap.get(formState.provider)?.modes : []) ??
|
||||
[];
|
||||
const providerDefinitions = snapshotProviderDefinitions;
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
@@ -437,7 +481,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
userModified,
|
||||
formStateRef.current,
|
||||
validServerIds,
|
||||
selectableProviderDefinitionMap,
|
||||
snapshotResolvableProviderDefinitionMap,
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
@@ -462,7 +506,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
availableModels,
|
||||
userModified,
|
||||
validServerIds,
|
||||
selectableProviderDefinitionMap,
|
||||
snapshotResolvableProviderDefinitionMap,
|
||||
]);
|
||||
|
||||
// Auto-select the first online host when:
|
||||
@@ -629,12 +673,17 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, [refetchSnapshotIfStale]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
if (!formState.provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = formState.provider;
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const modelId = resolvedModel?.id ?? formState.model;
|
||||
await updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: formState.provider,
|
||||
provider,
|
||||
updates: {
|
||||
model: modelId || undefined,
|
||||
mode: formState.modeId || undefined,
|
||||
@@ -657,7 +706,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
updatePreferences,
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||
const agentDefinition = formState.provider
|
||||
? providerDefinitionMap.get(formState.provider)
|
||||
: undefined;
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
@@ -738,6 +789,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
export type CreateAgentInitialValues = FormInitialValues;
|
||||
|
||||
export const __private__ = {
|
||||
buildProviderDefinitionMap,
|
||||
buildProviderDefinitionMapForStatuses,
|
||||
combineInitialValues,
|
||||
resolveDefaultModel,
|
||||
resolveFormState,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
type AttachmentUpdater =
|
||||
| ComposerAttachment[]
|
||||
@@ -113,7 +114,7 @@ function resolveEffectiveComposerThinkingOptionId(input: {
|
||||
}
|
||||
|
||||
function buildDraftComposerCommandConfig(input: {
|
||||
provider: DraftAgentStatusBarProps["selectedProvider"];
|
||||
provider: AgentProvider | null;
|
||||
cwd: string;
|
||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||
selectedMode: string;
|
||||
@@ -122,7 +123,7 @@ function buildDraftComposerCommandConfig(input: {
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!cwd) {
|
||||
if (!input.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type DraftFeatureConfig = Pick<
|
||||
|
||||
export function useDraftAgentFeatures(input: {
|
||||
serverId: string | null | undefined;
|
||||
provider: AgentProvider;
|
||||
provider: AgentProvider | null;
|
||||
cwd: string | null | undefined;
|
||||
modeId: string | null | undefined;
|
||||
modelId: string | null | undefined;
|
||||
@@ -28,30 +28,31 @@ export function useDraftAgentFeatures(input: {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
const normalizedProvider = provider ?? null;
|
||||
const persistedFeatureValues = useMemo(
|
||||
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
|
||||
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
|
||||
[preferences.providerPreferences, provider],
|
||||
);
|
||||
|
||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
||||
if (!normalizedCwd) {
|
||||
if (!normalizedProvider || !normalizedCwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
provider: normalizedProvider,
|
||||
cwd: normalizedCwd,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(modelId ? { model: modelId } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
|
||||
}, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
|
||||
|
||||
const featuresQuery = useQuery({
|
||||
queryKey: [
|
||||
"providerFeatures",
|
||||
serverId ?? null,
|
||||
provider,
|
||||
normalizedProvider,
|
||||
normalizedCwd || null,
|
||||
modeId ?? null,
|
||||
modelId ?? null,
|
||||
@@ -106,6 +107,9 @@ export function useDraftAgentFeatures(input: {
|
||||
|
||||
return { ...current, [featureId]: value };
|
||||
});
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
void updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
providersSnapshotQueryKey,
|
||||
shouldApplyProvidersSnapshotUpdate,
|
||||
useProvidersSnapshot,
|
||||
} from "./use-providers-snapshot";
|
||||
|
||||
const { mockClient, mockRuntime } = vi.hoisted(() => {
|
||||
const mockClient = {
|
||||
getProvidersSnapshot: vi.fn(),
|
||||
refreshProvidersSnapshot: vi.fn(),
|
||||
on: vi.fn(() => () => {}),
|
||||
};
|
||||
return {
|
||||
mockClient,
|
||||
mockRuntime: {
|
||||
client: mockClient,
|
||||
isConnected: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => mockRuntime.client,
|
||||
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
||||
}));
|
||||
|
||||
const serverId = "server-1";
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function enableProvidersSnapshot(): void {
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession(serverId, mockClient as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: "localhost",
|
||||
version: "test",
|
||||
features: { providersSnapshot: true },
|
||||
} as never);
|
||||
});
|
||||
}
|
||||
|
||||
function renderProvidersSnapshotHook(cwd?: string | null) {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(() => useProvidersSnapshot(serverId, cwd), { wrapper });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("providers snapshot hook cache scope", () => {
|
||||
it("uses no cwd in the settings query key", () => {
|
||||
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]);
|
||||
});
|
||||
|
||||
it("accepts concrete home update events for settings snapshots", () => {
|
||||
expect(shouldApplyProvidersSnapshotUpdate(undefined, "/Users/alex")).toBe(true);
|
||||
expect(shouldApplyProvidersSnapshotUpdate(null, "/home/alex")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps workspace snapshot updates scoped to matching cwd keys", () => {
|
||||
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex/project")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldApplyProvidersSnapshotUpdate("/Users/alex/project-a", "/Users/alex/project-b"),
|
||||
).toBe(false);
|
||||
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends no cwd for settings snapshot loads and refreshes", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue({
|
||||
entries: [],
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
requestId: "settings",
|
||||
});
|
||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||
acknowledged: true,
|
||||
requestId: "settings-refresh",
|
||||
});
|
||||
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh(["codex"]);
|
||||
});
|
||||
|
||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||
});
|
||||
|
||||
it("sends cwd for workspace snapshot loads and refreshes", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue({
|
||||
entries: [],
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
requestId: "workspace",
|
||||
});
|
||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||
acknowledged: true,
|
||||
requestId: "workspace-refresh",
|
||||
});
|
||||
|
||||
const { result } = renderProvidersSnapshotHook("/repo");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh(["codex"]);
|
||||
});
|
||||
|
||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
|
||||
cwd: "/repo",
|
||||
providers: ["codex"],
|
||||
});
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||
|
||||
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||
export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||
const trimmed = cwd?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
@@ -15,10 +15,33 @@ function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||
return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||||
}
|
||||
|
||||
export function shouldApplyProvidersSnapshotUpdate(
|
||||
currentCwd?: string | null,
|
||||
messageCwd?: string | null,
|
||||
): boolean {
|
||||
const currentCwdKey = normalizeProvidersSnapshotCwdKey(currentCwd);
|
||||
const messageCwdKey = normalizeProvidersSnapshotCwdKey(messageCwd);
|
||||
return messageCwdKey === currentCwdKey || (currentCwdKey === null && messageCwdKey === "~");
|
||||
}
|
||||
|
||||
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
|
||||
return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const;
|
||||
}
|
||||
|
||||
function providersSnapshotRequest(cwd?: string): { cwd?: string } {
|
||||
return cwd ? { cwd } : {};
|
||||
}
|
||||
|
||||
function refreshProvidersSnapshotRequest(
|
||||
cwd: string | undefined,
|
||||
providers: AgentProvider[] | undefined,
|
||||
): { cwd?: string; providers?: AgentProvider[] } {
|
||||
return {
|
||||
...providersSnapshotRequest(cwd),
|
||||
...(providers ? { providers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface UseProvidersSnapshotResult {
|
||||
entries: ProviderSnapshotEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
@@ -62,7 +85,7 @@ export function useProvidersSnapshot(
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot({ cwd: normalizedCwd });
|
||||
return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,7 +94,9 @@ export function useProvidersSnapshot(
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers });
|
||||
await client.refreshProvidersSnapshot(
|
||||
refreshProvidersSnapshotRequest(normalizedCwd, providers),
|
||||
);
|
||||
},
|
||||
});
|
||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||
@@ -85,8 +110,7 @@ export function useProvidersSnapshot(
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
const messageCwdKey = normalizeProvidersSnapshotCwdKey(message.payload.cwd);
|
||||
if (messageCwdKey !== normalizedCwdKey) {
|
||||
if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(queryKey, {
|
||||
@@ -99,6 +123,7 @@ export function useProvidersSnapshot(
|
||||
client,
|
||||
enabled,
|
||||
isConnected,
|
||||
normalizedCwd,
|
||||
normalizedCwdKey,
|
||||
queryClient,
|
||||
queryKey,
|
||||
@@ -108,9 +133,14 @@ export function useProvidersSnapshot(
|
||||
|
||||
const refresh = useCallback(
|
||||
async (providers?: AgentProvider[]) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await refreshSnapshot(providers);
|
||||
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
||||
queryClient.setQueryData(queryKey, snapshot);
|
||||
},
|
||||
[refreshSnapshot],
|
||||
[client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
|
||||
);
|
||||
|
||||
const refetchIfStale = useCallback(() => {
|
||||
@@ -139,6 +169,6 @@ export function prefetchProvidersSnapshot(
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: 60_000,
|
||||
queryFn: () => client.getProvidersSnapshot({ cwd: normalizedCwd }),
|
||||
queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user