mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 08:42:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6f364aaf | ||
|
|
3eab934412 | ||
|
|
9cb556ce95 |
No files matched your search
@@ -5,6 +5,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.',
|
||||
'ai.chat.modelCatalogRetry': 'Retry',
|
||||
'ai.chat.compactingContext': 'Compacting earlier context…',
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
|
||||
@@ -5,6 +5,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.',
|
||||
'ai.chat.modelCatalogRetry': 'Reintentar',
|
||||
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
|
||||
'ai.chat.modelCatalogRetry': 'Повторить',
|
||||
'ai.chat.contextCompacted': 'Контекст сжат',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
|
||||
@@ -5,6 +5,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
|
||||
'ai.chat.modelCatalogRetry': '重试',
|
||||
'ai.chat.compactingContext': '正在压缩较早的上下文…',
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
|
||||
@@ -5,6 +5,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
'ai.chat.preparing': '準備中…',
|
||||
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
|
||||
'ai.chat.modelCatalogRetry': '重試',
|
||||
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
|
||||
'ai.chat.compactingStep': '正在為下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
|
||||
|
||||
@@ -66,6 +66,10 @@ interface AIChatPanelContentProps {
|
||||
providerDisplayName?: string;
|
||||
modelDisplayName?: string;
|
||||
modelCatalogWarning?: string;
|
||||
/** Live SDK model catalog is loading (no built-in presets to show yet). */
|
||||
modelCatalogLoading?: boolean;
|
||||
/** Retry a failed SDK model catalog load from the warning banner. */
|
||||
handleModelCatalogRetry?: () => void;
|
||||
agentModelPresets: AgentModelPreset[];
|
||||
/** Suppress the composer picker's "use custom model" action (locked model). */
|
||||
allowCustomModelEntry?: boolean;
|
||||
@@ -131,6 +135,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
canCompact = false,
|
||||
handleSteer,
|
||||
handleStop,
|
||||
handleModelCatalogRetry,
|
||||
canSteer,
|
||||
isSteering,
|
||||
steerWarning,
|
||||
@@ -139,6 +144,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
providerDisplayName,
|
||||
modelDisplayName,
|
||||
modelCatalogWarning,
|
||||
modelCatalogLoading = false,
|
||||
agentModelPresets,
|
||||
allowCustomModelEntry = true,
|
||||
selectedAgentModel,
|
||||
@@ -335,8 +341,20 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
{steerWarning}
|
||||
</div>
|
||||
) : modelCatalogWarning ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
{modelCatalogWarning}
|
||||
<div role="status" className="mx-3 mb-1.5 flex items-center gap-2 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
<span className="min-w-0 flex-1">{modelCatalogWarning}</span>
|
||||
{handleModelCatalogRetry && (
|
||||
<button
|
||||
onClick={handleModelCatalogRetry}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium text-amber-600 underline-offset-2 transition-colors hover:bg-amber-500/10 hover:text-amber-500 hover:underline cursor-pointer dark:text-amber-400 dark:hover:text-amber-300"
|
||||
>
|
||||
{t('ai.chat.modelCatalogRetry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : modelCatalogLoading ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md bg-muted/40 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground/70">
|
||||
{t('ai.chat.loadingModels')}
|
||||
</div>
|
||||
) : null}
|
||||
<ChatInput
|
||||
@@ -357,6 +375,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
modelCatalogLoading={modelCatalogLoading}
|
||||
allowCustomModelEntry={allowCustomModelEntry}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
|
||||
@@ -321,6 +321,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
|
||||
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
|
||||
// Tracks SDK runtime model catalogs currently loading (per agent scope):
|
||||
// while an OpenCode catalog is pending we must not flash the built-in
|
||||
// OPENCODE_MODEL_PRESETS for providers the user never configured (#3584).
|
||||
const [runtimeModelLoading, setRuntimeModelLoading] = useState<Record<string, { cacheKey: string }>>({});
|
||||
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
|
||||
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
|
||||
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
|
||||
@@ -933,6 +937,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: runtimePresets } }
|
||||
));
|
||||
}
|
||||
// Another path (e.g. send preflight) applied the catalog before the
|
||||
// deferred idle callback scheduled in the visibility effect: clear its
|
||||
// stale pending flag so the composer stops claiming models are loading.
|
||||
setRuntimeModelLoading((prev) => {
|
||||
if (!prev[agentId] || prev[agentId].cacheKey !== target.cacheKey) return prev;
|
||||
const { [agentId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const normalizedStoredModelId = normalizeStoredAgentModelSelection(
|
||||
storedModelId,
|
||||
@@ -950,7 +962,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
) {
|
||||
setAgentModel(agentId, catalog.currentModelId);
|
||||
}
|
||||
}, [setAgentModel, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
|
||||
}, [setAgentModel, setRuntimeModelLoading, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
|
||||
|
||||
const loadSdkRuntimeModelCatalog = useCallback((
|
||||
target: SdkRuntimeModelTarget,
|
||||
@@ -1029,17 +1041,46 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
// even when the user never selected OpenCode (#2184). Manual refresh still
|
||||
// passes force via the model selector path. Defer the network refresh so
|
||||
// expand → first type does not wait on CLI model listing.
|
||||
const clearRuntimeModelLoading = () => {
|
||||
setRuntimeModelLoading((prev) => {
|
||||
if (!prev[target.agentId] || prev[target.agentId].cacheKey !== target.cacheKey) return prev;
|
||||
const { [target.agentId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
let cancelled = false;
|
||||
let idleFired = false;
|
||||
// Mark the catalog pending as soon as the refresh is scheduled: otherwise
|
||||
// `runtimeModelLoading` stays false for the whole idle delay (and longer
|
||||
// while the composer stays focused), so the built-in OPENCODE_MODEL_PRESETS
|
||||
// remain selectable the entire time (#3584). Only do this on a cache miss:
|
||||
// when a cached catalog was already applied above, the runtime presets are
|
||||
// available, so a pending flag would keep the composer banner/picker in the
|
||||
// "loading" state indefinitely while the idle callback is deferred.
|
||||
if (!cached) {
|
||||
setRuntimeModelLoading((prev) => (
|
||||
prev[target.agentId]?.cacheKey === target.cacheKey ? prev : { ...prev, [target.agentId]: { cacheKey: target.cacheKey } }
|
||||
));
|
||||
}
|
||||
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
||||
idleFired = true;
|
||||
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
|
||||
if (cancelled || !catalog) return;
|
||||
applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true });
|
||||
}).finally(() => {
|
||||
// Always clear, even after cancellation: a stale flag would keep the
|
||||
// composer in the loading state forever.
|
||||
clearRuntimeModelLoading();
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdle();
|
||||
// If the idle callback never fired, the load never started and nothing
|
||||
// would clear the pending flag — do it here. In-flight loads clear their
|
||||
// own flag in `.finally`, so leave them alone.
|
||||
if (!idleFired) clearRuntimeModelLoading();
|
||||
};
|
||||
}, [
|
||||
isVisible,
|
||||
@@ -1075,6 +1116,22 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
collectCustomModelIds(currentAgentId, runtimePresets),
|
||||
);
|
||||
}
|
||||
// While an OpenCode catalog load is pending, do not flash the built-in
|
||||
// OPENCODE_MODEL_PRESETS: they enumerate well-known gateway providers the
|
||||
// user may never have configured, so for a few seconds every switch looks
|
||||
// like a broken default model (#3584). Show only the user's own custom
|
||||
// model ids until the live catalog resolves (or a retryable warning
|
||||
// appears in the composer banner).
|
||||
if (
|
||||
target
|
||||
&& getExternalAgentSdkBackend(currentAgentConfig) === 'opencode'
|
||||
&& runtimeModelLoading[currentAgentId]?.cacheKey === target.cacheKey
|
||||
) {
|
||||
return appendComposerCustomModelPresets(
|
||||
[],
|
||||
collectCustomModelIds(currentAgentId, []),
|
||||
);
|
||||
}
|
||||
const presets = getAgentModelPresets(
|
||||
currentAgentConfig?.command,
|
||||
getExternalAgentSdkBackend(currentAgentConfig),
|
||||
@@ -1092,6 +1149,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentAgentConfig,
|
||||
currentAgentId,
|
||||
runtimeAgentModelPresets,
|
||||
runtimeModelLoading,
|
||||
buildExternalAgentRuntimeModelTarget,
|
||||
hasCodexCustomConfig,
|
||||
codexConfigModel,
|
||||
@@ -1127,6 +1185,31 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
}, [currentAgentId, setAgentModel]);
|
||||
|
||||
// Retry a failed SDK runtime model catalog load (composer warning banner).
|
||||
// Failures are never cached, so a forced refresh re-queries the CLI/daemon.
|
||||
const handleRetrySdkModelCatalog = useCallback(() => {
|
||||
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
if (!target) return;
|
||||
setRuntimeModelLoading((prev) => (
|
||||
prev[target.agentId]?.cacheKey === target.cacheKey ? prev : { ...prev, [target.agentId]: { cacheKey: target.cacheKey } }
|
||||
));
|
||||
void loadSdkRuntimeModelCatalog(target, { force: true }).then((catalog) => {
|
||||
if (!catalog) return;
|
||||
applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true });
|
||||
}).finally(() => {
|
||||
setRuntimeModelLoading((prev) => {
|
||||
if (!prev[target.agentId] || prev[target.agentId].cacheKey !== target.cacheKey) return prev;
|
||||
const { [target.agentId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
}, [
|
||||
currentAgentConfig,
|
||||
buildExternalAgentRuntimeModelTarget,
|
||||
loadSdkRuntimeModelCatalog,
|
||||
applySdkRuntimeModelCatalog,
|
||||
]);
|
||||
|
||||
const selectedCattyThinking = agentThinkingMap.catty;
|
||||
const handleCattyThinkingSelect = useCallback((level: string) => {
|
||||
setAgentThinking('catty', level);
|
||||
@@ -1794,6 +1877,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
&& runtimeModelWarnings[currentAgentId].cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
|
||||
? runtimeModelWarnings[currentAgentId].message
|
||||
: undefined}
|
||||
modelCatalogLoading={
|
||||
getExternalAgentSdkBackend(currentAgentConfig) === 'opencode'
|
||||
&& runtimeModelLoading[currentAgentId]?.cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
|
||||
}
|
||||
handleModelCatalogRetry={handleRetrySdkModelCatalog}
|
||||
agentModelPresets={agentModelPresets}
|
||||
// A managed Codex config's `model` field overrides every selection on
|
||||
// send, so the manual-entry action would be a silent no-op.
|
||||
|
||||
@@ -206,6 +206,8 @@ interface ChatInputProps {
|
||||
placeholder?: string;
|
||||
/** Available model presets for the current agent */
|
||||
modelPresets?: AgentModelPreset[];
|
||||
/** Live agent model catalog (OpenCode) is still loading. */
|
||||
modelCatalogLoading?: boolean;
|
||||
/**
|
||||
* Offer the picker's "use custom model" action. Disable when the current
|
||||
* agent locks the model (e.g. managed Codex `model` config overrides the
|
||||
@@ -277,6 +279,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
agentName,
|
||||
placeholder,
|
||||
modelPresets = [],
|
||||
modelCatalogLoading = false,
|
||||
allowCustomModelEntry = true,
|
||||
selectedModelId,
|
||||
onModelSelect,
|
||||
@@ -997,7 +1000,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
? providerSwitcherChipLabel
|
||||
: (selectedPreset?.name || modelName || providerName || t('ai.chat.noModel'));
|
||||
const modelChipMaxWidth = hasProviderSwitcher ? 'max-w-[168px]' : 'max-w-[96px]';
|
||||
const hasModelPicker = hasProviderSwitcher || (modelPresets.length > 0 && !!onModelSelect);
|
||||
// Keep the picker open-able while an OpenCode catalog load is pending so
|
||||
// the user sees the loading row instead of stale built-in presets (#3584).
|
||||
const hasModelPicker = hasProviderSwitcher
|
||||
|| ((modelPresets.length > 0 || modelCatalogLoading) && !!onModelSelect);
|
||||
const thinkingLevels = useMemo(
|
||||
() => (
|
||||
hasProviderSwitcher && onThinkingLevelChange
|
||||
@@ -1592,6 +1598,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
selectedProviderId={providerSwitcher?.selectedProviderId}
|
||||
selectedModelId={hasProviderSwitcher ? providerSwitcher?.selectedModelId : selectedBaseModelId}
|
||||
modelPresets={hasProviderSwitcher ? undefined : modelPresets}
|
||||
loading={!hasProviderSwitcher ? modelCatalogLoading : undefined}
|
||||
allowCustomEntry={allowCustomModelEntry}
|
||||
prefs={modelPrefs}
|
||||
onSelectProviderModel={(providerId, modelId, contextWindow) => {
|
||||
@@ -1805,6 +1812,7 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
|
||||
&& prev.agentName === next.agentName
|
||||
&& prev.placeholder === next.placeholder
|
||||
&& prev.modelPresets === next.modelPresets
|
||||
&& prev.modelCatalogLoading === next.modelCatalogLoading
|
||||
&& prev.allowCustomModelEntry === next.allowCustomModelEntry
|
||||
&& prev.selectedModelId === next.selectedModelId
|
||||
&& prev.onModelSelect === next.onModelSelect
|
||||
|
||||
@@ -109,3 +109,20 @@ test('external agent picker keeps preset rows truncation-friendly with a full-na
|
||||
|
||||
assert.match(html, /title="GPT-5\.6 Sol \(preview\)"/);
|
||||
});
|
||||
|
||||
test('external agent picker shows the loading row while the live catalog is pending', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ComposerModelPicker
|
||||
modelPresets={[]}
|
||||
loading
|
||||
selectedModelId={undefined}
|
||||
prefs={{ recent: [], pinned: [] }}
|
||||
onSelectModel={() => {}}
|
||||
onTogglePinned={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /ai\.chat\.loadingModels/);
|
||||
// The host no longer renders built-in presets while loading (#3584).
|
||||
assert.doesNotMatch(html, /gpt-5\.1/);
|
||||
});
|
||||
@@ -22,6 +22,11 @@ export interface ComposerModelPickerProps {
|
||||
selectedProviderId?: string;
|
||||
selectedModelId?: string;
|
||||
modelPresets?: AgentModelPreset[];
|
||||
/**
|
||||
* The host's live model catalog (external SDK agents like OpenCode) is still
|
||||
* loading. Show the loading row instead of an empty/placeholder list.
|
||||
*/
|
||||
loading?: boolean;
|
||||
prefs: ComposerModelPrefs;
|
||||
/**
|
||||
* Offer the "use custom model" action. Disable when the host locks the
|
||||
@@ -89,6 +94,7 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
|
||||
selectedProviderId,
|
||||
selectedModelId,
|
||||
modelPresets = [],
|
||||
loading = false,
|
||||
prefs,
|
||||
allowCustomEntry = true,
|
||||
onSelectProviderModel,
|
||||
@@ -271,7 +277,7 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="max-h-[280px] overflow-y-auto">
|
||||
{catalog.loading && (
|
||||
{(catalog.loading || (loading && !hasProviders)) && (
|
||||
<div className="flex h-8 items-center gap-1.5 px-2.5 text-[11px] text-muted-foreground/55">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
{t('ai.chat.loadingModels')}
|
||||
@@ -344,7 +350,7 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
|
||||
/>
|
||||
))}
|
||||
|
||||
{!catalog.loading && filtered.length === 0 && !showCustom && (
|
||||
{!catalog.loading && !loading && filtered.length === 0 && !showCustom && (
|
||||
<div className="px-2.5 py-2 text-[11px] text-muted-foreground/50">
|
||||
{catalog.error || t('ai.chat.noMatchingModels')}
|
||||
</div>
|
||||
|
||||
@@ -772,9 +772,12 @@ function buildOpenCodeListServerKey(binPath, env) {
|
||||
}
|
||||
|
||||
// Shared list-models servers: coalesce concurrent catalog loads for the same
|
||||
// binary, then tear down after a short idle so idle Netcatty does not keep
|
||||
// opencode processes around (issue #2184).
|
||||
const OPENCODE_LIST_SERVER_IDLE_MS = 1500;
|
||||
// binary, then tear down after an idle window so idle Netcatty does not keep
|
||||
// opencode processes around (issue #2184). The idle window stays generous
|
||||
// (30s, unref'd) because Windows opencode server cold starts take 4.6–8.9s
|
||||
// (#3584): killing the server 1.5s after a load makes every agent switch pay
|
||||
// the full spawn cost again and re-show the placeholder catalog.
|
||||
const OPENCODE_LIST_SERVER_IDLE_MS = 30000;
|
||||
const openCodeListServers = new Map();
|
||||
|
||||
function clearOpenCodeListServerIdle(entry) {
|
||||
@@ -907,8 +910,15 @@ async function listOpenCodeModels({ env, binPath, openCodeFactory, abortControll
|
||||
currentModelId: getOpenCodeDefaultModelId(data),
|
||||
models: mapOpenCodeModels(data),
|
||||
};
|
||||
} catch {
|
||||
return emptyOpenCodeModelCatalog();
|
||||
} catch (error) {
|
||||
// Surface catalog load failures to the caller so the UI can tell "still
|
||||
// loading" apart from "failed" (#3584). Silently swallowing spawn /
|
||||
// providers() errors meant a bad OPENCODE_BIN or a Windows cold-start
|
||||
// timeout degraded to an empty catalog with no error and no retry path.
|
||||
// An aborted load keeps the silent empty result: the caller cancelled on
|
||||
// purpose, so there is nothing to retry.
|
||||
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
|
||||
throw error;
|
||||
} finally {
|
||||
if (acquired) releaseOpenCodeListServer(acquired.key);
|
||||
}
|
||||
|
||||
@@ -1012,3 +1012,22 @@ test("classifyOpenCodeSpawnError recognizes missing opencode CLI", () => {
|
||||
assert.equal(classifyOpenCodeSpawnError(Object.assign(new Error("spawn opencode ENOENT"), { code: "ENOENT" })).isSpawnEnoent, true);
|
||||
assert.equal(classifyOpenCodeSpawnError(new Error("other")).isSpawnEnoent, false);
|
||||
});
|
||||
|
||||
test("listOpenCodeModels rethrows catalog failures instead of silently returning empty", async () => {
|
||||
resetOpenCodeListServerPool();
|
||||
await assert.rejects(
|
||||
listOpenCodeModels({
|
||||
binPath: "/tmp/opencode-error-test",
|
||||
openCodeFactory: async () => ({
|
||||
client: {
|
||||
config: {
|
||||
providers: async () => ({ error: { message: "provider fetch failed" } }),
|
||||
},
|
||||
},
|
||||
server: { close() {} },
|
||||
}),
|
||||
}),
|
||||
/provider fetch failed/,
|
||||
);
|
||||
resetOpenCodeListServerPool();
|
||||
});
|
||||
Reference in new issue
Block a user