Use parsed Claude models and remove auto defaults
This commit is contained in:
@@ -14,14 +14,14 @@ describe("getStatusSelectorHint", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeModelId", () => {
|
describe("normalizeModelId", () => {
|
||||||
it("treats empty and default values as unset", () => {
|
it("treats empty values as unset", () => {
|
||||||
expect(normalizeModelId("")).toBeNull();
|
expect(normalizeModelId("")).toBeNull();
|
||||||
expect(normalizeModelId(" default ")).toBeNull();
|
|
||||||
expect(normalizeModelId(undefined)).toBeNull();
|
expect(normalizeModelId(undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns trimmed model ids", () => {
|
it("returns trimmed model ids", () => {
|
||||||
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
||||||
|
expect(normalizeModelId(" default ")).toBe("default");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,4 +69,50 @@ describe("resolveAgentModelSelection", () => {
|
|||||||
expect(selection.selectedThinkingId).toBe("high");
|
expect(selection.selectedThinkingId).toBe("high");
|
||||||
expect(selection.displayThinking).toBe("High");
|
expect(selection.displayThinking).toBe("High");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the provider default model label instead of Auto", () => {
|
||||||
|
const selection = resolveAgentModelSelection({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
provider: "codex",
|
||||||
|
label: "Model A",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||||
|
defaultThinkingOptionId: "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeModelId: null,
|
||||||
|
configuredModelId: null,
|
||||||
|
explicitThinkingOptionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.displayModel).toBe("Model A");
|
||||||
|
expect(selection.displayThinking).toBe("Low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the configured model when runtime model is not in the model list", () => {
|
||||||
|
const selection = resolveAgentModelSelection({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
provider: "claude",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeModelId: "claude-sonnet-4-6-20260101",
|
||||||
|
configuredModelId: "default",
|
||||||
|
explicitThinkingOptionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.activeModelId).toBe("default");
|
||||||
|
expect(selection.displayModel).toBe("Default (Sonnet 4.6)");
|
||||||
|
expect(selection.selectedThinkingId).toBeNull();
|
||||||
|
expect(selection.displayThinking).toBe("Default");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ function ControlledStatusBar({
|
|||||||
const displayModel =
|
const displayModel =
|
||||||
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
||||||
? "Loading models..."
|
? "Loading models..."
|
||||||
: findOptionLabel(modelOptions, selectedModelId, "Auto");
|
: findOptionLabel(modelOptions, selectedModelId, "Select model");
|
||||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
|
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "Default");
|
||||||
|
|
||||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
@@ -777,7 +777,7 @@ export function DraftAgentStatusBar({
|
|||||||
label: definition.label,
|
label: definition.label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
|
const modelOptions: StatusOption[] = [];
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
modelOptions.push({ id: model.id, label: model.label });
|
modelOptions.push({ id: model.id, label: model.label });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
|
|||||||
|
|
||||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized || normalized.toLowerCase() === "default") {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -30,14 +30,22 @@ export function resolveAgentModelSelection(input: {
|
|||||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
||||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
||||||
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
||||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
const runtimeSelectedModel =
|
||||||
|
models && normalizedRuntimeModelId
|
||||||
|
? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null)
|
||||||
|
: null;
|
||||||
|
const preferredModelId =
|
||||||
|
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
||||||
|
const fallbackModel =
|
||||||
|
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||||
const selectedModel =
|
const selectedModel =
|
||||||
models && preferredModelId
|
models && preferredModelId
|
||||||
? (models.find((model) => model.id === preferredModelId) ?? null)
|
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
||||||
: null;
|
: fallbackModel;
|
||||||
|
|
||||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||||
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
|
const displayModel =
|
||||||
|
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
|
||||||
|
|
||||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||||
const selectedThinkingId =
|
const selectedThinkingId =
|
||||||
@@ -48,7 +56,7 @@ export function resolveAgentModelSelection(input: {
|
|||||||
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
|
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
|
||||||
const displayThinking =
|
const displayThinking =
|
||||||
selectedThinking?.label ??
|
selectedThinking?.label ??
|
||||||
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
|
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "Default"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedModel,
|
selectedModel,
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ const INLINE_MODEL_THRESHOLD = 8;
|
|||||||
|
|
||||||
type DrillDownView = { provider: string };
|
type DrillDownView = { provider: string };
|
||||||
|
|
||||||
|
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string | null {
|
||||||
|
if (!models || models.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
interface CombinedModelSelectorProps {
|
interface CombinedModelSelectorProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
@@ -66,9 +73,9 @@ export function CombinedModelSelector({
|
|||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
if (!models) return isLoading ? "Loading..." : "Auto";
|
if (!models) return isLoading ? "Loading..." : "Select model";
|
||||||
const model = models.find((m) => m.id === selectedModel);
|
const model = models.find((m) => m.id === selectedModel);
|
||||||
return model?.label ?? "Auto";
|
return model?.label ?? resolveDefaultModelLabel(models) ?? "Select model";
|
||||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -80,20 +80,14 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps provider thinking preference when it is valid for the effective model", () => {
|
it("prefers provider defaults on fresh drafts", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{ provider: "codex" },
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
thinkingOptionId: "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codexModels,
|
codexModels,
|
||||||
{
|
{
|
||||||
serverId: false,
|
serverId: false,
|
||||||
@@ -114,20 +108,14 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.thinkingOptionId).toBe("low");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to model default when saved thinking preference is invalid", () => {
|
it("falls back to model default when saved thinking preference is invalid", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{ provider: "codex" },
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
thinkingOptionId: "medium",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codexModels,
|
codexModels,
|
||||||
{
|
{
|
||||||
serverId: false,
|
serverId: false,
|
||||||
@@ -151,7 +139,7 @@ describe("useAgentFormState", () => {
|
|||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' from initial values to auto", () => {
|
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
{ model: "default" },
|
{ model: "default" },
|
||||||
{ provider: "codex" },
|
{ provider: "codex" },
|
||||||
@@ -175,20 +163,13 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
|
it("normalizes legacy model id 'default' to the provider default model", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
{ model: "default" },
|
||||||
{
|
{ provider: "codex" },
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codexModels,
|
codexModels,
|
||||||
{
|
{
|
||||||
serverId: false,
|
serverId: false,
|
||||||
@@ -209,7 +190,76 @@ describe("useAgentFormState", () => {
|
|||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit initial thinking option when it is valid", () => {
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
{ thinkingOptionId: "low" },
|
||||||
|
{ provider: "codex" },
|
||||||
|
codexModels,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves thinking unset when the model exposes options without a provider default", () => {
|
||||||
|
const claudeModels: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "default",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "claude" },
|
||||||
|
claudeModels,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "claude",
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("default");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves provider only from allowed provider map", () => {
|
it("resolves provider only from allowed provider map", () => {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "
|
|||||||
|
|
||||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized || normalized.toLowerCase() === "default") {
|
if (!normalized) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -117,6 +117,10 @@ function resolveDefaultModel(
|
|||||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
||||||
|
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function resolveEffectiveModel(
|
function resolveEffectiveModel(
|
||||||
availableModels: AgentModelDefinition[] | null,
|
availableModels: AgentModelDefinition[] | null,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
@@ -134,9 +138,31 @@ function resolveEffectiveModel(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveThinkingOptionId(args: {
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
modelId: string;
|
||||||
|
requestedThinkingOptionId: string;
|
||||||
|
}): string {
|
||||||
|
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
||||||
|
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
|
if (thinkingOptions.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
||||||
|
if (
|
||||||
|
normalizedThinkingOptionId &&
|
||||||
|
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
||||||
|
) {
|
||||||
|
return normalizedThinkingOptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveModel?.defaultThinkingOptionId ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function that resolves form state from multiple data sources.
|
* Pure function that resolves form state from multiple data sources.
|
||||||
* Priority: explicit (URL params) > preferences > provider defaults > fallback
|
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
||||||
*
|
*
|
||||||
* Only resolves fields that haven't been user-modified.
|
* Only resolves fields that haven't been user-modified.
|
||||||
*/
|
*/
|
||||||
@@ -171,8 +197,6 @@ function resolveFormState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const providerDef = allowedProviderMap.get(result.provider);
|
const providerDef = allowedProviderMap.get(result.provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[result.provider];
|
|
||||||
|
|
||||||
// 2. Resolve modeId (depends on provider)
|
// 2. Resolve modeId (depends on provider)
|
||||||
if (!userModified.modeId) {
|
if (!userModified.modeId) {
|
||||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
@@ -183,8 +207,6 @@ function resolveFormState(
|
|||||||
validModeIds.includes(initialValues.modeId)
|
validModeIds.includes(initialValues.modeId)
|
||||||
) {
|
) {
|
||||||
result.modeId = initialValues.modeId;
|
result.modeId = initialValues.modeId;
|
||||||
} else if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
|
||||||
result.modeId = providerPrefs.mode;
|
|
||||||
} else {
|
} else {
|
||||||
result.modeId = providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
result.modeId = providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
||||||
}
|
}
|
||||||
@@ -194,26 +216,18 @@ function resolveFormState(
|
|||||||
if (!userModified.model) {
|
if (!userModified.model) {
|
||||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||||
|
|
||||||
if (initialModel) {
|
if (initialModel) {
|
||||||
// If models aren't loaded yet, trust the initial value
|
// If models aren't loaded yet, trust the initial value
|
||||||
// It will be validated once models load
|
// It will be validated once models load
|
||||||
if (!availableModels || isValidModel(initialModel)) {
|
if (!availableModels || isValidModel(initialModel)) {
|
||||||
result.model = initialModel;
|
result.model = initialModel;
|
||||||
} else if (preferredModel && isValidModel(preferredModel)) {
|
|
||||||
result.model = preferredModel;
|
|
||||||
} else {
|
} else {
|
||||||
result.model = "";
|
result.model = defaultModelId;
|
||||||
}
|
|
||||||
} else if (preferredModel) {
|
|
||||||
// If models haven't loaded yet, optimistically apply the stored preference.
|
|
||||||
// We'll validate once models load and clear it if it isn't available.
|
|
||||||
if (!availableModels || isValidModel(preferredModel)) {
|
|
||||||
result.model = preferredModel;
|
|
||||||
} else {
|
|
||||||
result.model = "";
|
|
||||||
}
|
}
|
||||||
|
} else if (defaultModelId) {
|
||||||
|
result.model = defaultModelId;
|
||||||
} else {
|
} else {
|
||||||
result.model = "";
|
result.model = "";
|
||||||
}
|
}
|
||||||
@@ -224,13 +238,10 @@ function resolveFormState(
|
|||||||
typeof initialValues?.thinkingOptionId === "string"
|
typeof initialValues?.thinkingOptionId === "string"
|
||||||
? initialValues.thinkingOptionId.trim()
|
? initialValues.thinkingOptionId.trim()
|
||||||
: "";
|
: "";
|
||||||
const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? "";
|
|
||||||
|
|
||||||
if (!userModified.thinkingOptionId) {
|
if (!userModified.thinkingOptionId) {
|
||||||
if (initialThinkingOptionId.length > 0) {
|
if (initialThinkingOptionId.length > 0) {
|
||||||
result.thinkingOptionId = initialThinkingOptionId;
|
result.thinkingOptionId = initialThinkingOptionId;
|
||||||
} else if (preferredThinkingOptionId.length > 0) {
|
|
||||||
result.thinkingOptionId = preferredThinkingOptionId;
|
|
||||||
} else {
|
} else {
|
||||||
result.thinkingOptionId = "";
|
result.thinkingOptionId = "";
|
||||||
}
|
}
|
||||||
@@ -238,18 +249,11 @@ function resolveFormState(
|
|||||||
|
|
||||||
// Validate thinking option once model metadata is available.
|
// Validate thinking option once model metadata is available.
|
||||||
if (availableModels) {
|
if (availableModels) {
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, result.model);
|
result.thinkingOptionId = resolveThinkingOptionId({
|
||||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
availableModels,
|
||||||
if (thinkingOptions.length === 0) {
|
modelId: result.model,
|
||||||
result.thinkingOptionId = "";
|
requestedThinkingOptionId: result.thinkingOptionId,
|
||||||
} else {
|
});
|
||||||
const thinkingIds = new Set(thinkingOptions.map((option) => option.id));
|
|
||||||
const defaultThinkingOptionId =
|
|
||||||
effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
|
||||||
if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) {
|
|
||||||
result.thinkingOptionId = defaultThinkingOptionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Resolve serverId (independent)
|
// 5. Resolve serverId (independent)
|
||||||
@@ -313,7 +317,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
preferences,
|
preferences,
|
||||||
isLoading: isPreferencesLoading,
|
isLoading: isPreferencesLoading,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateProviderPreferences,
|
|
||||||
} = useFormPreferences();
|
} = useFormPreferences();
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
@@ -568,76 +571,89 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const setProviderFromUser = useCallback(
|
const setProviderFromUser = useCallback(
|
||||||
(provider: AgentProvider) => {
|
(provider: AgentProvider) => {
|
||||||
setFormState((prev) => ({ ...prev, provider }));
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
|
const providerDef = providerDefinitionMap.get(provider);
|
||||||
|
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||||
|
const defaultThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: providerModels,
|
||||||
|
modelId: defaultModelId,
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
});
|
||||||
|
|
||||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
setUserModified((prev) => ({ ...prev, provider: true }));
|
||||||
void updatePreferences({ provider });
|
void updatePreferences({ provider });
|
||||||
|
|
||||||
// When provider changes, reset mode and model to provider defaults
|
|
||||||
// (unless user has explicitly set them)
|
|
||||||
const providerDef = providerDefinitionMap.get(provider);
|
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
|
||||||
|
|
||||||
setFormState((prev) => ({
|
setFormState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
provider,
|
provider,
|
||||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
modeId: providerDef?.defaultModeId ?? "",
|
||||||
model: normalizeSelectedModelId(providerPrefs?.model),
|
model: defaultModelId,
|
||||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
thinkingOptionId: defaultThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
[allProviderModels, providerDefinitionMap, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setProviderAndModelFromUser = useCallback(
|
const setProviderAndModelFromUser = useCallback(
|
||||||
(provider: AgentProvider, modelId: string) => {
|
(provider: AgentProvider, modelId: string) => {
|
||||||
const providerDef = providerDefinitionMap.get(provider);
|
const providerDef = providerDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: providerModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
});
|
||||||
|
|
||||||
setFormState((prev) => ({
|
setFormState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
provider,
|
provider,
|
||||||
model: modelId,
|
model: nextModelId,
|
||||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
modeId: providerDef?.defaultModeId ?? "",
|
||||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||||
void updatePreferences({ provider });
|
void updatePreferences({ provider });
|
||||||
void updateProviderPreferences(provider, { model: modelId });
|
|
||||||
},
|
},
|
||||||
[
|
[allProviderModels, providerDefinitionMap, updatePreferences],
|
||||||
preferences?.providerPreferences,
|
|
||||||
providerDefinitionMap,
|
|
||||||
updatePreferences,
|
|
||||||
updateProviderPreferences,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
(modeId: string) => {
|
(modeId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, modeId }));
|
setFormState((prev) => ({ ...prev, modeId }));
|
||||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||||
void updateProviderPreferences(formState.provider, { mode: modeId });
|
|
||||||
},
|
},
|
||||||
[formState.provider, updateProviderPreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModelFromUser = useCallback(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string) => {
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
|
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: userModified.thinkingOptionId
|
||||||
|
? formStateRef.current.thinkingOptionId
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
model: nextModelId,
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, model: true }));
|
setUserModified((prev) => ({ ...prev, model: true }));
|
||||||
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
|
|
||||||
},
|
},
|
||||||
[formState.provider, updateProviderPreferences],
|
[availableModels, userModified.thinkingOptionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThinkingOptionFromUser = useCallback(
|
const setThinkingOptionFromUser = useCallback(
|
||||||
(thinkingOptionId: string) => {
|
(thinkingOptionId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
||||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
||||||
void updateProviderPreferences(formState.provider, { thinkingOptionId });
|
|
||||||
},
|
},
|
||||||
[formState.provider, updateProviderPreferences],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setWorkingDir = useCallback((value: string) => {
|
const setWorkingDir = useCallback((value: string) => {
|
||||||
@@ -662,34 +678,16 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, [providerModelsQuery]);
|
}, [providerModelsQuery]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
const providerPreferenceUpdates: {
|
|
||||||
mode: string;
|
|
||||||
model: string;
|
|
||||||
thinkingOptionId?: string;
|
|
||||||
} = {
|
|
||||||
mode: formState.modeId,
|
|
||||||
model: formState.model,
|
|
||||||
};
|
|
||||||
if (userModified.thinkingOptionId) {
|
|
||||||
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updatePreferences({
|
await updatePreferences({
|
||||||
workingDir: formState.workingDir,
|
workingDir: formState.workingDir,
|
||||||
provider: formState.provider,
|
provider: formState.provider,
|
||||||
serverId: formState.serverId ?? undefined,
|
serverId: formState.serverId ?? undefined,
|
||||||
});
|
});
|
||||||
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
|
|
||||||
}, [
|
}, [
|
||||||
formState.modeId,
|
|
||||||
formState.model,
|
|
||||||
formState.provider,
|
formState.provider,
|
||||||
formState.serverId,
|
formState.serverId,
|
||||||
formState.thinkingOptionId,
|
|
||||||
formState.workingDir,
|
formState.workingDir,
|
||||||
userModified.thinkingOptionId,
|
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateProviderPreferences,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||||
@@ -771,5 +769,7 @@ export type CreateAgentInitialValues = FormInitialValues;
|
|||||||
|
|
||||||
export const __private__ = {
|
export const __private__ = {
|
||||||
combineInitialValues,
|
combineInitialValues,
|
||||||
|
resolveDefaultModel,
|
||||||
resolveFormState,
|
resolveFormState,
|
||||||
|
resolveThinkingOptionId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,25 +2,15 @@ import { useCallback } from "react";
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||||
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||||
|
|
||||||
const providerPreferencesSchema = z.object({
|
|
||||||
model: z.string().optional(),
|
|
||||||
mode: z.string().optional(),
|
|
||||||
thinkingOptionId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const formPreferencesSchema = z.object({
|
const formPreferencesSchema = z.object({
|
||||||
workingDir: z.string().optional(),
|
workingDir: z.string().optional(),
|
||||||
provider: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
|
|
||||||
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
|
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
|
||||||
|
|
||||||
const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
|
const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
|
||||||
@@ -35,12 +25,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
|
|||||||
export interface UseFormPreferencesReturn {
|
export interface UseFormPreferencesReturn {
|
||||||
preferences: FormPreferences;
|
preferences: FormPreferences;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
getProviderPreferences: (provider: AgentProvider) => ProviderPreferences | undefined;
|
|
||||||
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
|
||||||
updateProviderPreferences: (
|
|
||||||
provider: AgentProvider,
|
|
||||||
updates: Partial<ProviderPreferences>,
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||||
@@ -54,13 +39,6 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
|||||||
|
|
||||||
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
||||||
|
|
||||||
const getProviderPreferences = useCallback(
|
|
||||||
(provider: AgentProvider): ProviderPreferences | undefined => {
|
|
||||||
return preferences.providerPreferences?.[provider];
|
|
||||||
},
|
|
||||||
[preferences.providerPreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatePreferences = useCallback(
|
const updatePreferences = useCallback(
|
||||||
async (updates: Partial<FormPreferences>) => {
|
async (updates: Partial<FormPreferences>) => {
|
||||||
const prev =
|
const prev =
|
||||||
@@ -73,32 +51,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
|||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateProviderPreferences = useCallback(
|
|
||||||
async (provider: AgentProvider, updates: Partial<ProviderPreferences>) => {
|
|
||||||
const prev =
|
|
||||||
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
|
|
||||||
DEFAULT_FORM_PREFERENCES;
|
|
||||||
const next: FormPreferences = {
|
|
||||||
...prev,
|
|
||||||
providerPreferences: {
|
|
||||||
...prev.providerPreferences,
|
|
||||||
[provider]: {
|
|
||||||
...prev.providerPreferences?.[provider],
|
|
||||||
...updates,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
|
||||||
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
preferences,
|
preferences,
|
||||||
isLoading: isPending,
|
isLoading: isPending,
|
||||||
getProviderPreferences,
|
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateProviderPreferences,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -744,6 +744,20 @@ function DraftAgentScreenContent({
|
|||||||
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
||||||
|
|
||||||
const createAgentClient = sessionClient;
|
const createAgentClient = sessionClient;
|
||||||
|
const effectiveDraftModelId = useMemo(() => {
|
||||||
|
if (selectedModel.trim()) {
|
||||||
|
return selectedModel.trim();
|
||||||
|
}
|
||||||
|
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||||
|
}, [availableModels, selectedModel]);
|
||||||
|
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||||
|
if (selectedThinkingOptionId.trim()) {
|
||||||
|
return selectedThinkingOptionId.trim();
|
||||||
|
}
|
||||||
|
const selectedModelDefinition =
|
||||||
|
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||||
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
|
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||||
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||||
const cwd = (
|
const cwd = (
|
||||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||||
@@ -756,18 +770,18 @@ function DraftAgentScreenContent({
|
|||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd,
|
cwd,
|
||||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||||
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||||
...(selectedThinkingOptionId.trim()
|
...(effectiveDraftThinkingOptionId
|
||||||
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
effectiveDraftModelId,
|
||||||
|
effectiveDraftThinkingOptionId,
|
||||||
isAttachWorktree,
|
isAttachWorktree,
|
||||||
modeOptions.length,
|
modeOptions.length,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
selectedModel,
|
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedThinkingOptionId,
|
|
||||||
selectedWorktreePath,
|
selectedWorktreePath,
|
||||||
workingDir,
|
workingDir,
|
||||||
]);
|
]);
|
||||||
@@ -801,6 +815,12 @@ function DraftAgentScreenContent({
|
|||||||
if (gitBlockingError) {
|
if (gitBlockingError) {
|
||||||
return gitBlockingError;
|
return gitBlockingError;
|
||||||
}
|
}
|
||||||
|
if (isModelLoading) {
|
||||||
|
return "Model defaults are still loading";
|
||||||
|
}
|
||||||
|
if (!effectiveDraftModelId) {
|
||||||
|
return "No model is available for the selected provider";
|
||||||
|
}
|
||||||
if (isAttachWorktree && !selectedWorktreePath) {
|
if (isAttachWorktree && !selectedWorktreePath) {
|
||||||
return "Select a worktree to attach";
|
return "Select a worktree to attach";
|
||||||
}
|
}
|
||||||
@@ -832,8 +852,8 @@ function DraftAgentScreenContent({
|
|||||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||||
".";
|
".";
|
||||||
const provider = selectedProvider;
|
const provider = selectedProvider;
|
||||||
const model = selectedModel.trim() || null;
|
const model = effectiveDraftModelId || null;
|
||||||
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -869,14 +889,14 @@ function DraftAgentScreenContent({
|
|||||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
|
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
|
||||||
|
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||||
const trimmedModel = selectedModel.trim();
|
|
||||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd: resolvedWorkingDir,
|
cwd: resolvedWorkingDir,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
...(effectiveDraftThinkingOptionId
|
||||||
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectiveBaseBranch = baseBranch.trim();
|
const effectiveBaseBranch = baseBranch.trim();
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ export function WorkspaceDraftAgentTab({
|
|||||||
setWorkingDir(workspaceId);
|
setWorkingDir(workspaceId);
|
||||||
}, [setWorkingDir, workingDir, workspaceId]);
|
}, [setWorkingDir, workingDir, workspaceId]);
|
||||||
|
|
||||||
|
const effectiveDraftModelId = useMemo(() => {
|
||||||
|
if (selectedModel.trim()) {
|
||||||
|
return selectedModel.trim();
|
||||||
|
}
|
||||||
|
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||||
|
}, [availableModels, selectedModel]);
|
||||||
|
|
||||||
|
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||||
|
if (selectedThinkingOptionId.trim()) {
|
||||||
|
return selectedThinkingOptionId.trim();
|
||||||
|
}
|
||||||
|
const selectedModelDefinition =
|
||||||
|
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||||
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
|
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
@@ -111,6 +127,12 @@ export function WorkspaceDraftAgentTab({
|
|||||||
if (providerDefinitions.length === 0) {
|
if (providerDefinitions.length === 0) {
|
||||||
return "No available providers on the selected host";
|
return "No available providers on the selected host";
|
||||||
}
|
}
|
||||||
|
if (isModelLoading) {
|
||||||
|
return "Model defaults are still loading";
|
||||||
|
}
|
||||||
|
if (!effectiveDraftModelId) {
|
||||||
|
return "No model is available for the selected provider";
|
||||||
|
}
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return "Host is not connected";
|
return "Host is not connected";
|
||||||
}
|
}
|
||||||
@@ -125,8 +147,8 @@ export function WorkspaceDraftAgentTab({
|
|||||||
},
|
},
|
||||||
buildDraftAgent: (attempt) => {
|
buildDraftAgent: (attempt) => {
|
||||||
const now = attempt.timestamp;
|
const now = attempt.timestamp;
|
||||||
const model = selectedModel.trim() || null;
|
const model = effectiveDraftModelId || null;
|
||||||
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
@@ -156,14 +178,14 @@ export function WorkspaceDraftAgentTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||||
const trimmedModel = selectedModel.trim();
|
|
||||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd: workspaceId,
|
cwd: workspaceId,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
...(effectiveDraftThinkingOptionId
|
||||||
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const imagesData = await encodeImages(images);
|
const imagesData = await encodeImages(images);
|
||||||
@@ -189,17 +211,17 @@ export function WorkspaceDraftAgentTab({
|
|||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd: workspaceId,
|
cwd: workspaceId,
|
||||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||||
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||||
...(selectedThinkingOptionId.trim()
|
...(effectiveDraftThinkingOptionId
|
||||||
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
effectiveDraftModelId,
|
||||||
|
effectiveDraftThinkingOptionId,
|
||||||
modeOptions.length,
|
modeOptions.length,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
selectedModel,
|
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedThinkingOptionId,
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ describe("extractAgentModel", () => {
|
|||||||
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
|
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats legacy 'default' model ids as unset", () => {
|
it("preserves 'default' as a valid model id", () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
model: "default",
|
model: "default",
|
||||||
runtimeInfo: { model: "default" },
|
runtimeInfo: { model: "default" },
|
||||||
} as Partial<Agent> as Agent;
|
} as Partial<Agent> as Agent;
|
||||||
|
|
||||||
expect(extractAgentModel(agent)).toBeNull();
|
expect(extractAgentModel(agent)).toBe("default");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ export function extractAgentModel(agent?: Agent | null): string | null {
|
|||||||
const fallbackModel = agent.model;
|
const fallbackModel = agent.model;
|
||||||
if (typeof runtimeModel === "string") {
|
if (typeof runtimeModel === "string") {
|
||||||
const normalized = runtimeModel.trim();
|
const normalized = runtimeModel.trim();
|
||||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
if (normalized.length > 0) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof fallbackModel === "string") {
|
if (typeof fallbackModel === "string") {
|
||||||
const normalized = fallbackModel.trim();
|
const normalized = fallbackModel.trim();
|
||||||
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
|
if (normalized.length > 0) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1954,6 +1954,7 @@ export class AgentManager {
|
|||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void this.refreshRuntimeInfo(agent);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "timeline":
|
case "timeline":
|
||||||
@@ -2369,8 +2370,7 @@ export class AgentManager {
|
|||||||
|
|
||||||
if (typeof normalized.model === "string") {
|
if (typeof normalized.model === "string") {
|
||||||
const trimmed = normalized.model.trim();
|
const trimmed = normalized.model.trim();
|
||||||
const normalizedId = trimmed.toLowerCase();
|
normalized.model = trimmed.length > 0 ? trimmed : undefined;
|
||||||
normalized.model = trimmed.length > 0 && normalizedId !== "default" ? trimmed : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
|
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
|
||||||
import { isCommandAvailable } from "../provider-launch-config.js";
|
import { isCommandAvailable } from "../provider-launch-config.js";
|
||||||
@@ -18,6 +19,10 @@ function tmpCwd(prefix: string): string {
|
|||||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||||
|
return (async function* empty() {})();
|
||||||
|
}
|
||||||
|
|
||||||
function compactText(value: string): string {
|
function compactText(value: string): string {
|
||||||
return value.replace(/\s+/g, "").toLowerCase();
|
return value.replace(/\s+/g, "").toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -208,6 +213,50 @@ describe("ClaudeAgentSession integration", () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
test.runIf(canRunClaudeIntegration)(
|
||||||
|
"supportedModels returns the current abstract Claude SDK model shape",
|
||||||
|
async () => {
|
||||||
|
const claudeQuery = query({
|
||||||
|
prompt: createEmptyPrompt(),
|
||||||
|
options: {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
permissionMode: "plan",
|
||||||
|
includePartialMessages: false,
|
||||||
|
settingSources: ["user", "project"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await claudeQuery.supportedModels();
|
||||||
|
|
||||||
|
expect(models.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(models).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "default",
|
||||||
|
displayName: "Default (recommended)",
|
||||||
|
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(models).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "haiku",
|
||||||
|
displayName: "Haiku",
|
||||||
|
description: expect.stringContaining("Haiku 4.5"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
models.some(
|
||||||
|
(model) =>
|
||||||
|
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await claudeQuery.return?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|
||||||
test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => {
|
test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => {
|
||||||
const handle = await createSession({
|
const handle = await createSession({
|
||||||
cwdPrefix: "claude-agent-basic-tool-",
|
cwdPrefix: "claude-agent-basic-tool-",
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { normalizeClaudeRuntimeModelId } from "./claude-agent.js";
|
|
||||||
import { CLAUDE_MODEL_CATALOG } from "./claude/model-catalog.js";
|
|
||||||
|
|
||||||
describe("normalizeClaudeRuntimeModelId", () => {
|
|
||||||
function latestModelId(family: "sonnet" | "opus" | "haiku"): string {
|
|
||||||
const latest = CLAUDE_MODEL_CATALOG.find(
|
|
||||||
(model) => model.family === family && model.isLatestInFamily,
|
|
||||||
);
|
|
||||||
if (latest) {
|
|
||||||
return latest.modelId;
|
|
||||||
}
|
|
||||||
const fallback = CLAUDE_MODEL_CATALOG.find((model) => model.family === family);
|
|
||||||
if (!fallback) {
|
|
||||||
throw new Error(`Missing Claude model family in catalog: ${family}`);
|
|
||||||
}
|
|
||||||
return fallback.modelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SONNET = latestModelId("sonnet");
|
|
||||||
const OPUS = latestModelId("opus");
|
|
||||||
const HAIKU = latestModelId("haiku");
|
|
||||||
const supportedModelIds = new Set([SONNET, OPUS, HAIKU]);
|
|
||||||
const supportedModelFamilyAliases = new Map([
|
|
||||||
["sonnet", SONNET],
|
|
||||||
["opus", OPUS],
|
|
||||||
["haiku", HAIKU],
|
|
||||||
] as const);
|
|
||||||
|
|
||||||
test("preserves runtime model when it already exists in the supported catalog", () => {
|
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
|
||||||
runtimeModelId: SONNET,
|
|
||||||
supportedModelIds,
|
|
||||||
});
|
|
||||||
expect(normalized).toBe(SONNET);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps unknown runtime Sonnet versions to the catalog Sonnet model ID", () => {
|
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
|
||||||
runtimeModelId: "claude-sonnet-4-6-20260101",
|
|
||||||
supportedModelIds,
|
|
||||||
supportedModelFamilyAliases,
|
|
||||||
});
|
|
||||||
expect(normalized).toBe(SONNET);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps unknown runtime Opus versions to the catalog Opus model ID", () => {
|
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
|
||||||
runtimeModelId: "claude-opus-4-5-20251101",
|
|
||||||
supportedModelIds,
|
|
||||||
supportedModelFamilyAliases,
|
|
||||||
});
|
|
||||||
expect(normalized).toBe(OPUS);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps unknown runtime Haiku versions to the catalog Haiku model ID", () => {
|
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
|
||||||
runtimeModelId: "claude-haiku-4-6-20260101",
|
|
||||||
supportedModelIds,
|
|
||||||
supportedModelFamilyAliases,
|
|
||||||
});
|
|
||||||
expect(normalized).toBe(HAIKU);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses configured model when runtime ID is unknown", () => {
|
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
|
||||||
runtimeModelId: "claude-custom-unknown",
|
|
||||||
supportedModelIds,
|
|
||||||
configuredModelId: OPUS,
|
|
||||||
});
|
|
||||||
expect(normalized).toBe(OPUS);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses current model when runtime and configured IDs are unknown", () => {
|
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
|
||||||
runtimeModelId: "claude-custom-unknown",
|
|
||||||
supportedModelIds,
|
|
||||||
configuredModelId: "claude-unknown",
|
|
||||||
currentModelId: HAIKU,
|
|
||||||
});
|
|
||||||
expect(normalized).toBe(HAIKU);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves runtime model when mapping is not possible", () => {
|
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
|
||||||
runtimeModelId: "claude-custom-unknown",
|
|
||||||
supportedModelIds: new Set(["x", "y"]),
|
|
||||||
});
|
|
||||||
expect(normalized).toBe("claude-custom-unknown");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not force family fallback for unknown runtime families", () => {
|
|
||||||
const normalized = normalizeClaudeRuntimeModelId({
|
|
||||||
runtimeModelId: "claude-custom-unknown",
|
|
||||||
supportedModelIds,
|
|
||||||
});
|
|
||||||
expect(normalized).toBe("claude-custom-unknown");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||||
@@ -241,6 +242,13 @@ describe("convertClaudeHistoryEntry", () => {
|
|||||||
describe("ClaudeAgentClient.listModels", () => {
|
describe("ClaudeAgentClient.listModels", () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
|
function createSupportedModelsQueryMock(models: ModelInfo[]) {
|
||||||
|
return {
|
||||||
|
supportedModels: vi.fn(async () => models),
|
||||||
|
return: vi.fn(async () => ({ done: true, value: undefined })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test("returns models with required fields", async () => {
|
test("returns models with required fields", async () => {
|
||||||
const client = new ClaudeAgentClient({ logger });
|
const client = new ClaudeAgentClient({ logger });
|
||||||
const models = await client.listModels();
|
const models = await client.listModels();
|
||||||
@@ -267,4 +275,87 @@ describe("ClaudeAgentClient.listModels", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
test("prefers provider-discovered Claude defaults and effort levels", async () => {
|
||||||
|
const queryMock = createSupportedModelsQueryMock([
|
||||||
|
{
|
||||||
|
value: "default",
|
||||||
|
displayName: "Default (recommended)",
|
||||||
|
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||||
|
supportsEffort: true,
|
||||||
|
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||||
|
supportsAdaptiveThinking: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "opus",
|
||||||
|
displayName: "Opus",
|
||||||
|
description: "Opus 4.6 · Most capable for complex work",
|
||||||
|
supportsEffort: true,
|
||||||
|
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||||
|
supportsAdaptiveThinking: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "haiku",
|
||||||
|
displayName: "Haiku",
|
||||||
|
description: "Haiku 4.5 · Fastest for quick answers",
|
||||||
|
},
|
||||||
|
] satisfies ModelInfo[]);
|
||||||
|
const queryFactory = vi.fn(() => queryMock);
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory: queryFactory as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await client.listModels({ cwd: process.cwd() });
|
||||||
|
|
||||||
|
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||||
|
expect(queryMock.supportedModels).toHaveBeenCalledTimes(1);
|
||||||
|
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||||
|
expect(models).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "claude-sonnet-4-6",
|
||||||
|
isDefault: true,
|
||||||
|
label: "Sonnet 4.6",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
{ id: "max", label: "Max" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "claude-opus-4-6",
|
||||||
|
label: "Opus 4.6",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "claude-haiku-4-5",
|
||||||
|
label: "Haiku 4.5",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves SDK ids even when descriptions are weak", async () => {
|
||||||
|
const queryMock = createSupportedModelsQueryMock([
|
||||||
|
{
|
||||||
|
value: "default",
|
||||||
|
displayName: "Default (recommended)",
|
||||||
|
description: "Recommended model",
|
||||||
|
},
|
||||||
|
] satisfies ModelInfo[]);
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory: vi.fn(() => queryMock) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await client.listModels({ cwd: process.cwd() });
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "default",
|
||||||
|
label: "Default (recommended)",
|
||||||
|
description: "Recommended model",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type AgentDefinition,
|
type AgentDefinition,
|
||||||
type CanUseTool,
|
type CanUseTool,
|
||||||
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
||||||
|
type ModelInfo,
|
||||||
type Options,
|
type Options,
|
||||||
type PermissionMode,
|
type PermissionMode,
|
||||||
type PermissionResult,
|
type PermissionResult,
|
||||||
@@ -33,11 +34,9 @@ import {
|
|||||||
mapTaskNotificationUserContentToToolCall,
|
mapTaskNotificationUserContentToToolCall,
|
||||||
} from "./claude/task-notification-tool-call.js";
|
} from "./claude/task-notification-tool-call.js";
|
||||||
import {
|
import {
|
||||||
buildClaudeModelFamilyAliases,
|
normalizeClaudeModelIdFromText,
|
||||||
buildClaudeSelectableModelIds,
|
resolveClaudeModelsFromSdkModels,
|
||||||
listClaudeCatalogModels,
|
} from "./claude/sdk-model-resolver.js";
|
||||||
type ClaudeModelFamily,
|
|
||||||
} from "./claude/model-catalog.js";
|
|
||||||
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
||||||
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
||||||
|
|
||||||
@@ -92,136 +91,6 @@ type AsyncMessageInput<T> = {
|
|||||||
iterable: AsyncIterable<T>;
|
iterable: AsyncIterable<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NormalizeClaudeRuntimeModelIdOptions = {
|
|
||||||
runtimeModelId: string;
|
|
||||||
supportedModelIds: ReadonlySet<string> | null;
|
|
||||||
supportedModelFamilyAliases?: ReadonlyMap<ClaudeModelFamily, string> | null;
|
|
||||||
configuredModelId?: string | null;
|
|
||||||
currentModelId?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeModelIdCandidate(modelId: string | null | undefined): string | null {
|
|
||||||
if (typeof modelId !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trimmed = modelId.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickSupportedModelId(
|
|
||||||
supportedModelIds: ReadonlySet<string>,
|
|
||||||
candidate: string | null | undefined,
|
|
||||||
): string | null {
|
|
||||||
const normalizedCandidate = normalizeModelIdCandidate(candidate);
|
|
||||||
if (!normalizedCandidate) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return supportedModelIds.has(normalizedCandidate) ? normalizedCandidate : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferClaudeModelFamilyFromText(text: string | null | undefined): ClaudeModelFamily | null {
|
|
||||||
if (typeof text !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const lowerText = text.toLowerCase();
|
|
||||||
if (lowerText.includes("sonnet")) {
|
|
||||||
return "sonnet";
|
|
||||||
}
|
|
||||||
if (lowerText.includes("opus")) {
|
|
||||||
return "opus";
|
|
||||||
}
|
|
||||||
if (lowerText.includes("haiku")) {
|
|
||||||
return "haiku";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFamilyAliasModelId(
|
|
||||||
familyAliases: ReadonlyMap<ClaudeModelFamily, string> | null | undefined,
|
|
||||||
family: ClaudeModelFamily,
|
|
||||||
): string | null {
|
|
||||||
if (!familyAliases) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return normalizeModelIdCandidate(familyAliases.get(family) ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeClaudeRuntimeModelId(
|
|
||||||
options: NormalizeClaudeRuntimeModelIdOptions,
|
|
||||||
): string {
|
|
||||||
const runtimeModel = options.runtimeModelId.trim();
|
|
||||||
if (!runtimeModel) {
|
|
||||||
return runtimeModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const supportedModelIds = options.supportedModelIds;
|
|
||||||
if (!supportedModelIds || supportedModelIds.size === 0) {
|
|
||||||
return runtimeModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportedModelIds.has(runtimeModel)) {
|
|
||||||
return runtimeModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeFamily = inferClaudeModelFamilyFromText(runtimeModel);
|
|
||||||
const familyAlias = runtimeFamily
|
|
||||||
? pickFamilyAliasModelId(options.supportedModelFamilyAliases, runtimeFamily)
|
|
||||||
: null;
|
|
||||||
if (runtimeFamily === "sonnet") {
|
|
||||||
const explicitSonnet = pickSupportedModelId(supportedModelIds, "sonnet");
|
|
||||||
if (explicitSonnet) {
|
|
||||||
return explicitSonnet;
|
|
||||||
}
|
|
||||||
if (familyAlias && supportedModelIds.has(familyAlias)) {
|
|
||||||
return familyAlias;
|
|
||||||
}
|
|
||||||
const defaultAlias = pickSupportedModelId(supportedModelIds, "default");
|
|
||||||
if (defaultAlias) {
|
|
||||||
return defaultAlias;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (runtimeFamily === "opus") {
|
|
||||||
const alias = pickSupportedModelId(supportedModelIds, "opus");
|
|
||||||
if (alias) {
|
|
||||||
return alias;
|
|
||||||
}
|
|
||||||
if (familyAlias && supportedModelIds.has(familyAlias)) {
|
|
||||||
return familyAlias;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (runtimeFamily === "haiku") {
|
|
||||||
const alias = pickSupportedModelId(supportedModelIds, "haiku");
|
|
||||||
if (alias) {
|
|
||||||
return alias;
|
|
||||||
}
|
|
||||||
if (familyAlias && supportedModelIds.has(familyAlias)) {
|
|
||||||
return familyAlias;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuredModelId = pickSupportedModelId(supportedModelIds, options.configuredModelId);
|
|
||||||
if (configuredModelId) {
|
|
||||||
return configuredModelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentModelId = pickSupportedModelId(supportedModelIds, options.currentModelId);
|
|
||||||
if (currentModelId) {
|
|
||||||
return currentModelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If Claude reports a concrete family ID we can't map directly, prefer the
|
|
||||||
// provider default alias for unconfigured sessions so UI model/thinking state
|
|
||||||
// can still reconcile against the current model catalog.
|
|
||||||
const defaultAlias = pickSupportedModelId(supportedModelIds, "default");
|
|
||||||
const hasConfiguredModel = normalizeModelIdCandidate(options.configuredModelId) !== null;
|
|
||||||
const hasCurrentModel = normalizeModelIdCandidate(options.currentModelId) !== null;
|
|
||||||
if (runtimeFamily && defaultAlias && !hasConfiguredModel && !hasCurrentModel) {
|
|
||||||
return defaultAlias;
|
|
||||||
}
|
|
||||||
|
|
||||||
return runtimeModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: true,
|
supportsSessionPersistence: true,
|
||||||
@@ -296,6 +165,8 @@ type ClaudeAgentSessionOptions = {
|
|||||||
queryFactory?: typeof query;
|
queryFactory?: typeof query;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClaudeThinkingEffort = "low" | "medium" | "high" | "max";
|
||||||
|
|
||||||
function resolveClaudeSpawnCommand(
|
function resolveClaudeSpawnCommand(
|
||||||
spawnOptions: SpawnOptions,
|
spawnOptions: SpawnOptions,
|
||||||
runtimeSettings?: ProviderRuntimeSettings,
|
runtimeSettings?: ProviderRuntimeSettings,
|
||||||
@@ -348,6 +219,14 @@ function applyRuntimeSettingsToClaudeOptions(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEmptyClaudePrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||||
|
return (async function* empty() {})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
|
||||||
|
return value === "low" || value === "medium" || value === "high" || value === "max";
|
||||||
|
}
|
||||||
|
|
||||||
type ClaudeOptionsLogSummary = {
|
type ClaudeOptionsLogSummary = {
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
permissionMode: string | null;
|
permissionMode: string | null;
|
||||||
@@ -1144,8 +1023,34 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
return listClaudeCatalogModels();
|
const claudeQuery = this.queryFactory({
|
||||||
|
prompt: createEmptyClaudePrompt(),
|
||||||
|
options: applyRuntimeSettingsToClaudeOptions(
|
||||||
|
{
|
||||||
|
cwd: options?.cwd ?? process.cwd(),
|
||||||
|
permissionMode: "plan",
|
||||||
|
includePartialMessages: false,
|
||||||
|
settingSources: CLAUDE_SETTING_SOURCES,
|
||||||
|
},
|
||||||
|
this.runtimeSettings,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supportedModels = await claudeQuery.supportedModels();
|
||||||
|
return resolveClaudeModelsFromSdkModels(supportedModels as ModelInfo[]);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, "Failed to query Claude supportedModels()");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await claudeQuery.return?.();
|
||||||
|
} catch {
|
||||||
|
// ignore control-plane shutdown errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listPersistedAgents(
|
async listPersistedAgents(
|
||||||
@@ -1223,9 +1128,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private cancelCurrentTurn: (() => void) | null = null;
|
private cancelCurrentTurn: (() => void) | null = null;
|
||||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||||
private lastOptionsModel: string | null = null;
|
private lastOptionsModel: string | null = null;
|
||||||
private selectableModelIds: Set<string> | null = buildClaudeSelectableModelIds();
|
private lastRuntimeModel: string | null = null;
|
||||||
private selectableModelFamilyAliases: Map<ClaudeModelFamily, string> | null =
|
|
||||||
buildClaudeModelFamilyAliases();
|
|
||||||
private compacting = false;
|
private compacting = false;
|
||||||
private queryPumpPromise: Promise<void> | null = null;
|
private queryPumpPromise: Promise<void> | null = null;
|
||||||
private queryRestartNeeded = false;
|
private queryRestartNeeded = false;
|
||||||
@@ -1281,6 +1184,13 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
sessionId: this.claudeSessionId,
|
sessionId: this.claudeSessionId,
|
||||||
model: this.lastOptionsModel,
|
model: this.lastOptionsModel,
|
||||||
modeId: this.currentMode ?? null,
|
modeId: this.currentMode ?? null,
|
||||||
|
...(this.lastRuntimeModel
|
||||||
|
? {
|
||||||
|
extra: {
|
||||||
|
runtimeModel: this.lastRuntimeModel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
this.cachedRuntimeInfo = info;
|
this.cachedRuntimeInfo = info;
|
||||||
return { ...info };
|
return { ...info };
|
||||||
@@ -1511,6 +1421,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
await query.setModel(normalizedModelId ?? undefined);
|
await query.setModel(normalizedModelId ?? undefined);
|
||||||
this.config.model = normalizedModelId ?? undefined;
|
this.config.model = normalizedModelId ?? undefined;
|
||||||
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
|
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
|
||||||
|
this.lastRuntimeModel = null;
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
// Model change affects persistence metadata, so invalidate cached handle.
|
// Model change affects persistence metadata, so invalidate cached handle.
|
||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
@@ -1524,10 +1435,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
|
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
|
||||||
this.config.thinkingOptionId = undefined;
|
this.config.thinkingOptionId = undefined;
|
||||||
} else if (normalizedThinkingOptionId === "on") {
|
} else if (isClaudeThinkingEffort(normalizedThinkingOptionId)) {
|
||||||
this.config.thinkingOptionId = "on";
|
this.config.thinkingOptionId = normalizedThinkingOptionId;
|
||||||
} else if (normalizedThinkingOptionId === "off") {
|
|
||||||
this.config.thinkingOptionId = "off";
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
|
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
|
||||||
}
|
}
|
||||||
@@ -1914,16 +1823,15 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildOptions(): ClaudeOptions {
|
private buildOptions(): ClaudeOptions {
|
||||||
const configuredThinkingOptionId = this.config.thinkingOptionId;
|
|
||||||
const thinkingOptionId =
|
const thinkingOptionId =
|
||||||
configuredThinkingOptionId && configuredThinkingOptionId !== "default"
|
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
|
||||||
? configuredThinkingOptionId
|
? this.config.thinkingOptionId
|
||||||
: "off";
|
: undefined;
|
||||||
let maxThinkingTokens: number | undefined;
|
let thinking: ClaudeOptions["thinking"];
|
||||||
if (thinkingOptionId === "on") {
|
let effort: ClaudeOptions["effort"];
|
||||||
maxThinkingTokens = 10000;
|
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
|
||||||
} else if (thinkingOptionId === "off") {
|
thinking = { type: "adaptive" };
|
||||||
maxThinkingTokens = 0;
|
effort = thinkingOptionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appendedSystemPrompt = [
|
const appendedSystemPrompt = [
|
||||||
@@ -1963,7 +1871,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
// If we have a session ID from a previous query (e.g., after interrupt),
|
// If we have a session ID from a previous query (e.g., after interrupt),
|
||||||
// resume that session to continue the conversation history.
|
// resume that session to continue the conversation history.
|
||||||
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
||||||
...(maxThinkingTokens !== undefined ? { maxThinkingTokens } : {}),
|
...(thinking ? { thinking } : {}),
|
||||||
|
...(effort ? { effort } : {}),
|
||||||
...this.config.extra?.claude,
|
...this.config.extra?.claude,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2704,15 +2613,17 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.currentMode = message.permissionMode;
|
this.currentMode = message.permissionMode;
|
||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
if (message.model) {
|
if (message.model) {
|
||||||
const normalizedModel = normalizeClaudeRuntimeModelId({
|
const normalizedRuntimeModel = normalizeClaudeModelIdFromText(message.model);
|
||||||
runtimeModelId: message.model,
|
this.logger.debug(
|
||||||
supportedModelIds: this.selectableModelIds,
|
{ runtimeModel: message.model, normalizedRuntimeModel },
|
||||||
supportedModelFamilyAliases: this.selectableModelFamilyAliases,
|
"Captured runtime model from SDK init",
|
||||||
configuredModelId: this.config.model ?? null,
|
);
|
||||||
currentModelId: this.lastOptionsModel,
|
if (normalizedRuntimeModel) {
|
||||||
});
|
this.lastOptionsModel = normalizedRuntimeModel;
|
||||||
this.logger.debug({ model: message.model, normalizedModel }, "Captured model from SDK init");
|
} else if (!this.lastOptionsModel) {
|
||||||
this.lastOptionsModel = normalizedModel;
|
this.lastOptionsModel = this.config.model ?? null;
|
||||||
|
}
|
||||||
|
this.lastRuntimeModel = message.model;
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
}
|
}
|
||||||
return threadStartedSessionId;
|
return threadStartedSessionId;
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Temporary hardcoded Claude model catalog.
|
|
||||||
*
|
|
||||||
* Why:
|
|
||||||
* - Claude SDK model discovery currently returns abstract options like
|
|
||||||
* "default", "opus", and "haiku".
|
|
||||||
* - Runtime init messages report concrete model IDs like
|
|
||||||
* "claude-opus-4-6".
|
|
||||||
* - That mismatch breaks model selection + thinking reconciliation in UI.
|
|
||||||
*
|
|
||||||
* We keep a single flat list with all model data in one place.
|
|
||||||
* If Claude SDK model discovery becomes consistent with runtime IDs, switch
|
|
||||||
* listModels back to SDK discovery and remove this file.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ClaudeCatalogModel = {
|
|
||||||
family: "sonnet" | "opus" | "haiku";
|
|
||||||
modelId: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
isDefault?: boolean;
|
|
||||||
isLatestInFamily?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CLAUDE_MODEL_CATALOG: readonly ClaudeCatalogModel[] = [
|
|
||||||
{
|
|
||||||
family: "opus",
|
|
||||||
modelId: "claude-opus-4-6",
|
|
||||||
name: "Opus 4.6",
|
|
||||||
description: "Opus 4.6 · Most capable for complex work",
|
|
||||||
isLatestInFamily: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
family: "sonnet",
|
|
||||||
modelId: "claude-sonnet-4-6",
|
|
||||||
name: "Sonnet 4.6",
|
|
||||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
|
||||||
isLatestInFamily: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
family: "sonnet",
|
|
||||||
modelId: "claude-sonnet-4-5-20250929",
|
|
||||||
name: "Sonnet 4.5",
|
|
||||||
description: "Sonnet 4.5 · Best for everyday tasks",
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
family: "haiku",
|
|
||||||
modelId: "claude-haiku-4-5-20251001",
|
|
||||||
name: "Haiku 4.5",
|
|
||||||
description: "Haiku 4.5 · Fastest for quick answers",
|
|
||||||
isLatestInFamily: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export type ClaudeModelFamily = ClaudeCatalogModel["family"];
|
|
||||||
|
|
||||||
function toClaudeModelDefinition(params: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
isDefault?: boolean;
|
|
||||||
}): AgentModelDefinition {
|
|
||||||
return {
|
|
||||||
provider: "claude",
|
|
||||||
id: params.id,
|
|
||||||
label: params.label,
|
|
||||||
description: params.description,
|
|
||||||
...(params.isDefault ? { isDefault: true } : {}),
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "off", label: "Off", isDefault: true },
|
|
||||||
{ id: "on", label: "On" },
|
|
||||||
],
|
|
||||||
defaultThinkingOptionId: "off",
|
|
||||||
metadata: params.description
|
|
||||||
? {
|
|
||||||
description: params.description,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listClaudeCatalogModels(): AgentModelDefinition[] {
|
|
||||||
return CLAUDE_MODEL_CATALOG.map((model) =>
|
|
||||||
toClaudeModelDefinition({
|
|
||||||
id: model.modelId,
|
|
||||||
label: model.name,
|
|
||||||
description: model.description,
|
|
||||||
isDefault: model.isDefault,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildClaudeSelectableModelIds(): Set<string> {
|
|
||||||
return new Set(CLAUDE_MODEL_CATALOG.map((model) => model.modelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildClaudeModelFamilyAliases(): Map<ClaudeModelFamily, string> {
|
|
||||||
const aliases = new Map<ClaudeModelFamily, string>();
|
|
||||||
for (const model of CLAUDE_MODEL_CATALOG) {
|
|
||||||
if (model.isLatestInFamily || !aliases.has(model.family)) {
|
|
||||||
aliases.set(model.family, model.modelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return aliases;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseClaudeSdkModelDescriptorForTest,
|
||||||
|
resolveClaudeModelsFromSdkModels,
|
||||||
|
} from "./sdk-model-resolver.js";
|
||||||
|
|
||||||
|
describe("resolveClaudeModelsFromSdkModels", () => {
|
||||||
|
const sdkModels: ModelInfo[] = [
|
||||||
|
{
|
||||||
|
value: "default",
|
||||||
|
displayName: "Default (recommended)",
|
||||||
|
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||||
|
supportsEffort: true,
|
||||||
|
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||||
|
supportsAdaptiveThinking: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "opus",
|
||||||
|
displayName: "Opus",
|
||||||
|
description: "Opus 4.6 · Most capable for complex work",
|
||||||
|
supportsEffort: true,
|
||||||
|
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||||
|
supportsAdaptiveThinking: true,
|
||||||
|
supportsFastMode: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "sonnet",
|
||||||
|
displayName: "Sonnet",
|
||||||
|
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||||
|
supportsEffort: true,
|
||||||
|
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||||
|
supportsAdaptiveThinking: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "haiku",
|
||||||
|
displayName: "Haiku",
|
||||||
|
description: "Haiku 4.5 · Fastest for quick answers",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("parses family and version from SDK descriptions", () => {
|
||||||
|
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[0]!)).toEqual({
|
||||||
|
family: "sonnet",
|
||||||
|
version: "4.6",
|
||||||
|
});
|
||||||
|
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[1]!)).toEqual({
|
||||||
|
family: "opus",
|
||||||
|
version: "4.6",
|
||||||
|
});
|
||||||
|
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[2]!)).toEqual({
|
||||||
|
family: "sonnet",
|
||||||
|
version: "4.6",
|
||||||
|
});
|
||||||
|
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[3]!)).toEqual({
|
||||||
|
family: "haiku",
|
||||||
|
version: "4.5",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps SDK models to parsed Claude model ids", () => {
|
||||||
|
const models = resolveClaudeModelsFromSdkModels(sdkModels);
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: "claude",
|
||||||
|
id: "claude-sonnet-4-6",
|
||||||
|
label: "Sonnet 4.6",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
{ id: "max", label: "Max" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: "claude",
|
||||||
|
id: "claude-opus-4-6",
|
||||||
|
label: "Opus 4.6",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: "claude",
|
||||||
|
id: "claude-haiku-4-5",
|
||||||
|
label: "Haiku 4.5",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
|
import type { AgentModelDefinition, AgentSelectOption } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
|
type ParsedClaudeSdkModelDescriptor = {
|
||||||
|
family: "opus" | "sonnet" | "haiku";
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeWhitespace(value: string): string {
|
||||||
|
return value.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(value: string): string {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClaudeVersionId(version: string): string {
|
||||||
|
return version.replace(/\./g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClaudeModelId(parsed: ParsedClaudeSdkModelDescriptor): string {
|
||||||
|
return `claude-${parsed.family}-${normalizeClaudeVersionId(parsed.version)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClaudeSdkDescriptor(model: ModelInfo): ParsedClaudeSdkModelDescriptor | null {
|
||||||
|
const description = normalizeWhitespace(model.description ?? "");
|
||||||
|
if (!description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = description.match(/\b(opus|sonnet|haiku)\s+(\d+(?:\.\d+)*)\b/i);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const family = match[1].toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
|
||||||
|
const version = match[2]!;
|
||||||
|
return { family, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeClaudeModelIdFromText(value: string | null | undefined): string | null {
|
||||||
|
const normalized = normalizeWhitespace(value ?? "");
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeMatch = normalized.match(/\b(opus|sonnet|haiku)[-_ ]+(\d+(?:[-.]\d+)*)\b/i);
|
||||||
|
if (!runtimeMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const family = runtimeMatch[1]!.toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
|
||||||
|
const version = runtimeMatch[2]!.replace(/-/g, ".");
|
||||||
|
return buildClaudeModelId({ family, version });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelLabel(model: ModelInfo): string {
|
||||||
|
const parsed = parseClaudeSdkDescriptor(model);
|
||||||
|
if (!parsed) {
|
||||||
|
return normalizeWhitespace(model.displayName || model.value);
|
||||||
|
}
|
||||||
|
return `${titleCase(parsed.family)} ${parsed.version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThinkingOptions(model: ModelInfo): {
|
||||||
|
thinkingOptions?: AgentSelectOption[];
|
||||||
|
defaultThinkingOptionId?: string;
|
||||||
|
} {
|
||||||
|
const effortLevels = model.supportedEffortLevels ?? [];
|
||||||
|
if (!model.supportsEffort || effortLevels.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const thinkingOptions: AgentSelectOption[] = effortLevels.map((level) => ({
|
||||||
|
id: level,
|
||||||
|
label: titleCase(level),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
thinkingOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveClaudeModelsFromSdkModels(models: ModelInfo[]): AgentModelDefinition[] {
|
||||||
|
const resolved = new Map<string, AgentModelDefinition>();
|
||||||
|
|
||||||
|
for (const model of models) {
|
||||||
|
const thinking = buildThinkingOptions(model);
|
||||||
|
const parsed = parseClaudeSdkDescriptor(model);
|
||||||
|
const id = parsed ? buildClaudeModelId(parsed) : model.value;
|
||||||
|
const existing = resolved.get(id);
|
||||||
|
resolved.set(id, {
|
||||||
|
provider: "claude",
|
||||||
|
id,
|
||||||
|
label: buildModelLabel(model),
|
||||||
|
description: normalizeWhitespace(model.description ?? model.displayName ?? model.value),
|
||||||
|
isDefault:
|
||||||
|
existing?.isDefault === true || model.value.trim().toLowerCase() === "default" || undefined,
|
||||||
|
...(thinking.thinkingOptions || existing?.thinkingOptions
|
||||||
|
? { thinkingOptions: thinking.thinkingOptions ?? existing?.thinkingOptions }
|
||||||
|
: {}),
|
||||||
|
...(thinking.defaultThinkingOptionId || existing?.defaultThinkingOptionId
|
||||||
|
? {
|
||||||
|
defaultThinkingOptionId:
|
||||||
|
thinking.defaultThinkingOptionId ?? existing?.defaultThinkingOptionId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
metadata: {
|
||||||
|
sdkValues: Array.from(
|
||||||
|
new Set([...(Array.isArray(existing?.metadata?.sdkValues) ? existing.metadata.sdkValues : []), model.value]),
|
||||||
|
),
|
||||||
|
sdkDisplayNames: Array.from(
|
||||||
|
new Set([
|
||||||
|
...(Array.isArray(existing?.metadata?.sdkDisplayNames) ? existing.metadata.sdkDisplayNames : []),
|
||||||
|
model.displayName,
|
||||||
|
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
|
||||||
|
),
|
||||||
|
sdkDescriptions: Array.from(
|
||||||
|
new Set([
|
||||||
|
...(Array.isArray(existing?.metadata?.sdkDescriptions) ? existing.metadata.sdkDescriptions : []),
|
||||||
|
model.description,
|
||||||
|
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
|
||||||
|
),
|
||||||
|
supportsEffort: model.supportsEffort === true,
|
||||||
|
supportedEffortLevels: model.supportedEffortLevels,
|
||||||
|
supportsAdaptiveThinking: model.supportsAdaptiveThinking === true,
|
||||||
|
supportsFastMode: model.supportsFastMode === true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(resolved.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseClaudeSdkModelDescriptorForTest(
|
||||||
|
model: ModelInfo,
|
||||||
|
): ParsedClaudeSdkModelDescriptor | null {
|
||||||
|
return parseClaudeSdkDescriptor(model);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ import type { AgentSnapshotPayload } from "./messages.js";
|
|||||||
*/
|
*/
|
||||||
describe("client activity tracking", () => {
|
describe("client activity tracking", () => {
|
||||||
const TEST_PROVIDER = "claude";
|
const TEST_PROVIDER = "claude";
|
||||||
const TEST_MODEL = "claude-haiku-4-5";
|
const TEST_MODEL = "haiku";
|
||||||
const TEST_CWD = "/tmp";
|
const TEST_CWD = "/tmp";
|
||||||
let daemon: TestPaseoDaemon;
|
let daemon: TestPaseoDaemon;
|
||||||
let client1: DaemonClient;
|
let client1: DaemonClient;
|
||||||
|
|||||||
Reference in New Issue
Block a user