diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ff7151e81..79bf149f5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) - Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) - Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) - Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 55eeaedd7e0..0ab9e6875eb 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -65,6 +65,7 @@ import { import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; +import { resolveConfiguredCronModelSuggestions } from "./views/agents-utils.ts"; import { renderAgents } from "./views/agents.ts"; import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; @@ -178,6 +179,7 @@ export function renderApp(state: AppViewState) { new Set( [ ...state.cronModelSuggestions, + ...resolveConfiguredCronModelSuggestions(configValue), ...state.cronJobs .map((job) => { if (job.payload.kind !== "agentTurn" || typeof job.payload.model !== "string") { diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index f63fbcab5b8..56f2cf6ef73 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveEffectiveModelFallbacks } from "./agents-utils.ts"; +import { + resolveConfiguredCronModelSuggestions, + resolveEffectiveModelFallbacks, +} from "./agents-utils.ts"; describe("resolveEffectiveModelFallbacks", () => { it("inherits defaults when no entry fallbacks are configured", () => { @@ -40,3 +43,47 @@ describe("resolveEffectiveModelFallbacks", () => { expect(resolveEffectiveModelFallbacks(entryModel, defaultModel)).toEqual([]); }); }); + +describe("resolveConfiguredCronModelSuggestions", () => { + it("collects defaults primary/fallbacks, alias map keys, and per-agent model entries", () => { + const result = resolveConfiguredCronModelSuggestions({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.2", + fallbacks: ["google/gemini-2.5-pro", "openai/gpt-5.2-mini"], + }, + models: { + "anthropic/claude-sonnet-4-5": { alias: "smart" }, + "openai/gpt-5.2": { alias: "main" }, + }, + }, + list: { + writer: { + model: { primary: "xai/grok-4", fallbacks: ["openai/gpt-5.2-mini"] }, + }, + planner: { + model: "google/gemini-2.5-flash", + }, + }, + }, + }); + + expect(result).toEqual([ + "anthropic/claude-sonnet-4-5", + "google/gemini-2.5-flash", + "google/gemini-2.5-pro", + "openai/gpt-5.2", + "openai/gpt-5.2-mini", + "xai/grok-4", + ]); + }); + + it("returns empty array for invalid or missing config shape", () => { + expect(resolveConfiguredCronModelSuggestions(null)).toEqual([]); + expect(resolveConfiguredCronModelSuggestions({})).toEqual([]); + expect(resolveConfiguredCronModelSuggestions({ agents: { defaults: { model: "" } } })).toEqual( + [], + ); + }); +}); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 3b72f5e36fb..9c3f18c355d 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -251,6 +251,77 @@ export function resolveEffectiveModelFallbacks( return resolveModelFallbacks(entryModel) ?? resolveModelFallbacks(defaultModel); } +function addModelId(target: Set, value: unknown) { + if (typeof value !== "string") { + return; + } + const trimmed = value.trim(); + if (!trimmed) { + return; + } + target.add(trimmed); +} + +function addModelConfigIds(target: Set, modelConfig: unknown) { + if (!modelConfig) { + return; + } + if (typeof modelConfig === "string") { + addModelId(target, modelConfig); + return; + } + if (typeof modelConfig !== "object") { + return; + } + const record = modelConfig as Record; + addModelId(target, record.primary); + addModelId(target, record.model); + addModelId(target, record.id); + addModelId(target, record.value); + const fallbacks = Array.isArray(record.fallbacks) + ? record.fallbacks + : Array.isArray(record.fallback) + ? record.fallback + : []; + for (const fallback of fallbacks) { + addModelId(target, fallback); + } +} + +export function resolveConfiguredCronModelSuggestions( + configForm: Record | null, +): string[] { + if (!configForm || typeof configForm !== "object") { + return []; + } + const agents = (configForm as { agents?: unknown }).agents; + if (!agents || typeof agents !== "object") { + return []; + } + const out = new Set(); + const defaults = (agents as { defaults?: unknown }).defaults; + if (defaults && typeof defaults === "object") { + const defaultsRecord = defaults as Record; + addModelConfigIds(out, defaultsRecord.model); + const defaultsModels = defaultsRecord.models; + if (defaultsModels && typeof defaultsModels === "object") { + for (const modelId of Object.keys(defaultsModels as Record)) { + addModelId(out, modelId); + } + } + } + const list = (agents as { list?: unknown }).list; + if (list && typeof list === "object") { + for (const entry of Object.values(list as Record)) { + if (!entry || typeof entry !== "object") { + continue; + } + addModelConfigIds(out, (entry as Record).model); + } + } + return [...out].toSorted((a, b) => a.localeCompare(b)); +} + export function parseFallbackList(value: string): string[] { return value .split(",")