Compare commits

...
Author SHA1 Message Date
netcatty-bot 6a6f364aaf fix: address Codex review on PR #3585 2026-10-02 22:26:01 +00:00
netcatty-bot 3eab934412 fix: address Codex review on PR #3585 2026-10-02 21:49:40 +00:00
netcatty-bot 9cb556ce95 fix(#3584): automated AI fix 2026-10-02 16:02:16 +00:00
12 changed files with 183 additions and 11 deletions

No files matched your search

+1
View File
@@ -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…',
+1
View File
@@ -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…',
+1
View File
@@ -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-провайдеров, агентов и параметров безопасности',
+1
View File
@@ -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': '请求过大,正在压缩上下文并重试…',
+1
View File
@@ -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': '請求過大,正在壓縮上下文並重試…',
+21 -2
View File
@@ -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}
+89 -1
View File
@@ -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.
+9 -1
View File
@@ -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/);
});
+8 -2
View File
@@ -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();
});