From ab8fe760ef5551c37f9250b07eb020e340faf4e1 Mon Sep 17 00:00:00 2001
From: Felix <24791380+vcfgv@users.noreply.github.com>
Date: Wed, 25 Mar 2026 10:13:11 +0800
Subject: [PATCH] feat(agent-model): add per-agent model override with
default-reset UX and runtime sync (#651)
---
README.ja-JP.md | 1 +
README.md | 1 +
README.zh-CN.md | 1 +
electron/api/routes/agents.ts | 23 +-
.../providers/provider-runtime-sync.ts | 135 ++++++++
electron/utils/agent-config.ts | 71 ++++-
electron/utils/openclaw-auth.ts | 42 ++-
src/i18n/locales/en/agents.json | 18 ++
src/i18n/locales/ja/agents.json | 18 ++
src/i18n/locales/zh/agents.json | 18 ++
src/pages/Agents/index.tsx | 296 +++++++++++++++++-
src/stores/agents.ts | 21 ++
src/types/agent.ts | 3 +
tests/unit/agent-config.test.ts | 95 ++++++
tests/unit/agents-page.test.tsx | 100 +++++-
tests/unit/provider-runtime-sync.test.ts | 54 ++++
16 files changed, 871 insertions(+), 26 deletions(-)
diff --git a/README.ja-JP.md b/README.ja-JP.md
index c9e4cd538..673927dc7 100644
--- a/README.ja-JP.md
+++ b/README.ja-JP.md
@@ -100,6 +100,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
+各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
diff --git a/README.md b/README.md
index 0a51bdcc6..322d31381 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,7 @@ Complete the entire setup—from installation to your first AI interaction—thr
### 💬 Intelligent Chat Interface
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
+Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
### 📡 Multi-Channel Management
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 8be813ede..264010877 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -101,6 +101,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
### 💬 智能聊天界面
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
+每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
diff --git a/electron/api/routes/agents.ts b/electron/api/routes/agents.ts
index dd0973959..be2b9dfad 100644
--- a/electron/api/routes/agents.ts
+++ b/electron/api/routes/agents.ts
@@ -7,10 +7,11 @@ import {
listAgentsSnapshot,
removeAgentWorkspaceDirectory,
resolveAccountIdForAgent,
+ updateAgentModel,
updateAgentName,
} from '../../utils/agent-config';
import { deleteChannelAccountConfig } from '../../utils/channel-config';
-import { syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync';
+import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
@@ -151,6 +152,26 @@ export async function handleAgentRoutes(
return true;
}
+ if (parts.length === 2 && parts[1] === 'model') {
+ try {
+ const body = await parseJsonBody<{ modelRef?: string | null }>(req);
+ const agentId = decodeURIComponent(parts[0]);
+ const snapshot = await updateAgentModel(agentId, body.modelRef ?? null);
+ try {
+ await syncAllProviderAuthToRuntime();
+ // Ensure this agent's runtime model registry reflects the new model override.
+ await syncAgentModelOverrideToRuntime(agentId);
+ } catch (syncError) {
+ console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
+ }
+ scheduleGatewayReload(ctx, 'update-agent-model');
+ sendJson(res, 200, { success: true, ...snapshot });
+ } catch (error) {
+ sendJson(res, 500, { success: false, error: String(error) });
+ }
+ return true;
+ }
+
if (parts.length === 3 && parts[1] === 'channels') {
try {
const agentId = decodeURIComponent(parts[0]);
diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts
index 6d6234e71..a0af1d359 100644
--- a/electron/services/providers/provider-runtime-sync.ts
+++ b/electron/services/providers/provider-runtime-sync.ts
@@ -12,8 +12,10 @@ import {
setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
+ updateSingleAgentModelProvider,
} from '../../utils/openclaw-auth';
import { logger } from '../../utils/logger';
+import { listAgentsSnapshot } from '../../utils/agent-config';
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
@@ -336,6 +338,116 @@ async function syncProviderToRuntime(
return context;
}
+function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null {
+ const trimmed = modelRef.trim();
+ const separatorIndex = trimmed.indexOf('/');
+ if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
+ return null;
+ }
+
+ return {
+ providerKey: trimmed.slice(0, separatorIndex),
+ modelId: trimmed.slice(separatorIndex + 1),
+ };
+}
+
+async function buildRuntimeProviderConfigMap(): Promise
{agent.id}
-
+
+
+ {agent.modelRef || defaultModelRef || '-'}
+
+
@@ -587,6 +646,233 @@ function AgentSettingsModal({
+ {showModelModal && (
+ setShowModelModal(false)}
+ />
+ )}
+
+ );
+}
+
+function AgentModelModal({
+ agent,
+ onClose,
+}: {
+ agent: AgentSummary;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation('agents');
+ const providerAccounts = useProviderStore((state) => state.accounts);
+ const providerStatuses = useProviderStore((state) => state.statuses);
+ const providerVendors = useProviderStore((state) => state.vendors);
+ const providerDefaultAccountId = useProviderStore((state) => state.defaultAccountId);
+ const { updateAgentModel, defaultModelRef } = useAgentsStore();
+ const [selectedRuntimeProviderKey, setSelectedRuntimeProviderKey] = useState('');
+ const [modelIdInput, setModelIdInput] = useState('');
+ const [savingModel, setSavingModel] = useState(false);
+
+ const runtimeProviderOptions = useMemo(() => {
+ const vendorMap = new Map(providerVendors.map((vendor) => [vendor.id, vendor]));
+ const statusById = new Map(providerStatuses.map((status) => [status.id, status]));
+ const entries = providerAccounts
+ .filter((account) => account.enabled && hasConfiguredProviderCredentials(account, statusById))
+ .sort((left, right) => {
+ if (left.id === providerDefaultAccountId) return -1;
+ if (right.id === providerDefaultAccountId) return 1;
+ return right.updatedAt.localeCompare(left.updatedAt);
+ });
+
+ const deduped = new Map();
+ for (const account of entries) {
+ const runtimeProviderKey = resolveRuntimeProviderKey(account);
+ if (!runtimeProviderKey || deduped.has(runtimeProviderKey)) continue;
+ const vendor = vendorMap.get(account.vendorId);
+ const label = `${account.label} (${vendor?.name || account.vendorId})`;
+ const configuredModelId = account.model
+ ? (account.model.startsWith(`${runtimeProviderKey}/`)
+ ? account.model.slice(runtimeProviderKey.length + 1)
+ : account.model)
+ : undefined;
+
+ deduped.set(runtimeProviderKey, {
+ runtimeProviderKey,
+ accountId: account.id,
+ label,
+ modelIdPlaceholder: vendor?.modelIdPlaceholder,
+ configuredModelId,
+ });
+ }
+
+ return [...deduped.values()];
+ }, [providerAccounts, providerDefaultAccountId, providerStatuses, providerVendors]);
+
+ useEffect(() => {
+ const override = splitModelRef(agent.overrideModelRef);
+ if (override) {
+ setSelectedRuntimeProviderKey(override.providerKey);
+ setModelIdInput(override.modelId);
+ return;
+ }
+
+ const effective = splitModelRef(agent.modelRef || defaultModelRef);
+ if (effective) {
+ setSelectedRuntimeProviderKey(effective.providerKey);
+ setModelIdInput(effective.modelId);
+ return;
+ }
+
+ setSelectedRuntimeProviderKey(runtimeProviderOptions[0]?.runtimeProviderKey || '');
+ setModelIdInput('');
+ }, [agent.modelRef, agent.overrideModelRef, defaultModelRef, runtimeProviderOptions]);
+
+ const selectedProvider = runtimeProviderOptions.find((option) => option.runtimeProviderKey === selectedRuntimeProviderKey) || null;
+ const trimmedModelId = modelIdInput.trim();
+ const nextModelRef = selectedRuntimeProviderKey && trimmedModelId
+ ? `${selectedRuntimeProviderKey}/${trimmedModelId}`
+ : '';
+ const normalizedDefaultModelRef = (defaultModelRef || '').trim();
+ const isUsingDefaultModelInForm = Boolean(normalizedDefaultModelRef) && nextModelRef === normalizedDefaultModelRef;
+ const currentOverrideModelRef = (agent.overrideModelRef || '').trim();
+ const desiredOverrideModelRef = nextModelRef && nextModelRef !== normalizedDefaultModelRef
+ ? nextModelRef
+ : null;
+ const modelChanged = (desiredOverrideModelRef || '') !== currentOverrideModelRef;
+
+ const handleSaveModel = async () => {
+ if (!selectedRuntimeProviderKey) {
+ toast.error(t('toast.agentModelProviderRequired'));
+ return;
+ }
+ if (!trimmedModelId) {
+ toast.error(t('toast.agentModelIdRequired'));
+ return;
+ }
+ if (!modelChanged) return;
+ if (!nextModelRef.includes('/')) {
+ toast.error(t('toast.agentModelInvalid'));
+ return;
+ }
+
+ setSavingModel(true);
+ try {
+ await updateAgentModel(agent.id, desiredOverrideModelRef);
+ toast.success(desiredOverrideModelRef ? t('toast.agentModelUpdated') : t('toast.agentModelReset'));
+ onClose();
+ } catch (error) {
+ toast.error(t('toast.agentModelUpdateFailed', { error: String(error) }));
+ } finally {
+ setSavingModel(false);
+ }
+ };
+
+ const handleUseDefaultModel = () => {
+ const parsedDefault = splitModelRef(normalizedDefaultModelRef);
+ if (!parsedDefault) {
+ setSelectedRuntimeProviderKey('');
+ setModelIdInput('');
+ return;
+ }
+ setSelectedRuntimeProviderKey(parsedDefault.providerKey);
+ setModelIdInput(parsedDefault.modelId);
+ };
+
+ return (
+
+
+
+
+
+ {t('settingsDialog.modelLabel')}
+
+
+ {t('settingsDialog.modelOverrideDescription', { defaultModel: defaultModelRef || '-' })}
+
+
+
+
+
+
+
+
+
+
+
+ setModelIdInput(event.target.value)}
+ placeholder={selectedProvider?.modelIdPlaceholder || selectedProvider?.configuredModelId || t('settingsDialog.modelIdPlaceholder')}
+ className={inputClasses}
+ />
+
+ {!!nextModelRef && (
+
+ {t('settingsDialog.modelPreview')}: {nextModelRef}
+
+ )}
+ {runtimeProviderOptions.length === 0 && (
+
+ {t('settingsDialog.modelProviderEmpty')}
+
+ )}
+
+
+
+
+
+
+
);
}
diff --git a/src/stores/agents.ts b/src/stores/agents.ts
index 22b01d323..7aacf02df 100644
--- a/src/stores/agents.ts
+++ b/src/stores/agents.ts
@@ -6,6 +6,7 @@ import type { AgentSummary, AgentsSnapshot } from '@/types/agent';
interface AgentsState {
agents: AgentSummary[];
defaultAgentId: string;
+ defaultModelRef: string | null;
configuredChannelTypes: string[];
channelOwners: Record;
channelAccountOwners: Record;
@@ -14,6 +15,7 @@ interface AgentsState {
fetchAgents: () => Promise;
createAgent: (name: string, options?: { inheritWorkspace?: boolean }) => Promise;
updateAgent: (agentId: string, name: string) => Promise;
+ updateAgentModel: (agentId: string, modelRef: string | null) => Promise;
deleteAgent: (agentId: string) => Promise;
assignChannel: (agentId: string, channelType: ChannelType) => Promise;
removeChannel: (agentId: string, channelType: ChannelType) => Promise;
@@ -24,6 +26,7 @@ function applySnapshot(snapshot: AgentsSnapshot | undefined) {
return snapshot ? {
agents: snapshot.agents ?? [],
defaultAgentId: snapshot.defaultAgentId ?? 'main',
+ defaultModelRef: snapshot.defaultModelRef ?? null,
configuredChannelTypes: snapshot.configuredChannelTypes ?? [],
channelOwners: snapshot.channelOwners ?? {},
channelAccountOwners: snapshot.channelAccountOwners ?? {},
@@ -33,6 +36,7 @@ function applySnapshot(snapshot: AgentsSnapshot | undefined) {
export const useAgentsStore = create((set) => ({
agents: [],
defaultAgentId: 'main',
+ defaultModelRef: null,
configuredChannelTypes: [],
channelOwners: {},
channelAccountOwners: {},
@@ -83,6 +87,23 @@ export const useAgentsStore = create((set) => ({
}
},
+ updateAgentModel: async (agentId: string, modelRef: string | null) => {
+ set({ error: null });
+ try {
+ const snapshot = await hostApiFetch(
+ `/api/agents/${encodeURIComponent(agentId)}/model`,
+ {
+ method: 'PUT',
+ body: JSON.stringify({ modelRef }),
+ }
+ );
+ set(applySnapshot(snapshot));
+ } catch (error) {
+ set({ error: String(error) });
+ throw error;
+ }
+ },
+
deleteAgent: async (agentId: string) => {
set({ error: null });
try {
diff --git a/src/types/agent.ts b/src/types/agent.ts
index b154833f3..b286c7999 100644
--- a/src/types/agent.ts
+++ b/src/types/agent.ts
@@ -3,6 +3,8 @@ export interface AgentSummary {
name: string;
isDefault: boolean;
modelDisplay: string;
+ modelRef?: string | null;
+ overrideModelRef?: string | null;
inheritedModel: boolean;
workspace: string;
agentDir: string;
@@ -13,6 +15,7 @@ export interface AgentSummary {
export interface AgentsSnapshot {
agents: AgentSummary[];
defaultAgentId: string;
+ defaultModelRef?: string | null;
configuredChannelTypes: string[];
channelOwners: Record;
channelAccountOwners: Record;
diff --git a/tests/unit/agent-config.test.ts b/tests/unit/agent-config.test.ts
index 3304717eb..77256acaa 100644
--- a/tests/unit/agent-config.test.ts
+++ b/tests/unit/agent-config.test.ts
@@ -102,6 +102,101 @@ describe('agent config lifecycle', () => {
);
});
+ it('exposes effective and override model refs in the snapshot', async () => {
+ await writeOpenClawJson({
+ agents: {
+ defaults: {
+ model: {
+ primary: 'moonshot/kimi-k2.5',
+ },
+ },
+ list: [
+ { id: 'main', name: 'Main', default: true },
+ { id: 'coder', name: 'Coder', model: { primary: 'ark/ark-code-latest' } },
+ ],
+ },
+ });
+
+ const { listAgentsSnapshot } = await import('@electron/utils/agent-config');
+ const snapshot = await listAgentsSnapshot();
+ const main = snapshot.agents.find((agent) => agent.id === 'main');
+ const coder = snapshot.agents.find((agent) => agent.id === 'coder');
+
+ expect(snapshot.defaultModelRef).toBe('moonshot/kimi-k2.5');
+ expect(main).toMatchObject({
+ modelRef: 'moonshot/kimi-k2.5',
+ overrideModelRef: null,
+ inheritedModel: true,
+ modelDisplay: 'kimi-k2.5',
+ });
+ expect(coder).toMatchObject({
+ modelRef: 'ark/ark-code-latest',
+ overrideModelRef: 'ark/ark-code-latest',
+ inheritedModel: false,
+ modelDisplay: 'ark-code-latest',
+ });
+ });
+
+ it('updates and clears per-agent model overrides', async () => {
+ await writeOpenClawJson({
+ agents: {
+ defaults: {
+ model: {
+ primary: 'moonshot/kimi-k2.5',
+ },
+ },
+ list: [
+ { id: 'main', name: 'Main', default: true },
+ { id: 'coder', name: 'Coder' },
+ ],
+ },
+ });
+
+ const { listAgentsSnapshot, updateAgentModel } = await import('@electron/utils/agent-config');
+
+ await updateAgentModel('coder', 'ark/ark-code-latest');
+ let config = await readOpenClawJson();
+ let coder = ((config.agents as { list: Array<{ id: string; model?: { primary?: string } }> }).list)
+ .find((agent) => agent.id === 'coder');
+ expect(coder?.model?.primary).toBe('ark/ark-code-latest');
+
+ let snapshot = await listAgentsSnapshot();
+ let snapshotCoder = snapshot.agents.find((agent) => agent.id === 'coder');
+ expect(snapshotCoder).toMatchObject({
+ modelRef: 'ark/ark-code-latest',
+ overrideModelRef: 'ark/ark-code-latest',
+ inheritedModel: false,
+ });
+
+ await updateAgentModel('coder', null);
+ config = await readOpenClawJson();
+ coder = ((config.agents as { list: Array<{ id: string; model?: unknown }> }).list)
+ .find((agent) => agent.id === 'coder');
+ expect(coder?.model).toBeUndefined();
+
+ snapshot = await listAgentsSnapshot();
+ snapshotCoder = snapshot.agents.find((agent) => agent.id === 'coder');
+ expect(snapshotCoder).toMatchObject({
+ modelRef: 'moonshot/kimi-k2.5',
+ overrideModelRef: null,
+ inheritedModel: true,
+ });
+ });
+
+ it('rejects invalid model ref formats when updating agent model', async () => {
+ await writeOpenClawJson({
+ agents: {
+ list: [{ id: 'main', name: 'Main', default: true }],
+ },
+ });
+
+ const { updateAgentModel } = await import('@electron/utils/agent-config');
+
+ await expect(updateAgentModel('main', 'invalid-model-ref')).rejects.toThrow(
+ 'modelRef must be in "provider/model" format',
+ );
+ });
+
it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => {
await writeOpenClawJson({
agents: {
diff --git a/tests/unit/agents-page.test.tsx b/tests/unit/agents-page.test.tsx
index 46adec1e0..4be182bc9 100644
--- a/tests/unit/agents-page.test.tsx
+++ b/tests/unit/agents-page.test.tsx
@@ -1,21 +1,31 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, render, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Agents } from '../../src/pages/Agents/index';
const hostApiFetchMock = vi.fn();
const subscribeHostEventMock = vi.fn();
const fetchAgentsMock = vi.fn();
+const updateAgentMock = vi.fn();
+const updateAgentModelMock = vi.fn();
+const refreshProviderSnapshotMock = vi.fn();
-const { gatewayState, agentsState } = vi.hoisted(() => ({
+const { gatewayState, agentsState, providersState } = vi.hoisted(() => ({
gatewayState: {
status: { state: 'running', port: 18789 },
},
agentsState: {
agents: [] as Array>,
+ defaultModelRef: null as string | null,
loading: false,
error: null as string | null,
},
+ providersState: {
+ accounts: [] as Array>,
+ statuses: [] as Array>,
+ vendors: [] as Array>,
+ defaultAccountId: '' as string,
+ },
}));
vi.mock('@/stores/gateway', () => ({
@@ -25,12 +35,16 @@ vi.mock('@/stores/gateway', () => ({
vi.mock('@/stores/agents', () => ({
useAgentsStore: (selector?: (state: typeof agentsState & {
fetchAgents: typeof fetchAgentsMock;
+ updateAgent: typeof updateAgentMock;
+ updateAgentModel: typeof updateAgentModelMock;
createAgent: ReturnType;
deleteAgent: ReturnType;
}) => unknown) => {
const state = {
...agentsState,
fetchAgents: fetchAgentsMock,
+ updateAgent: updateAgentMock,
+ updateAgentModel: updateAgentModelMock,
createAgent: vi.fn(),
deleteAgent: vi.fn(),
};
@@ -38,6 +52,18 @@ vi.mock('@/stores/agents', () => ({
},
}));
+vi.mock('@/stores/providers', () => ({
+ useProviderStore: (selector: (state: typeof providersState & {
+ refreshProviderSnapshot: typeof refreshProviderSnapshotMock;
+ }) => unknown) => {
+ const state = {
+ ...providersState,
+ refreshProviderSnapshot: refreshProviderSnapshotMock,
+ };
+ return selector(state);
+ },
+}));
+
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
@@ -64,7 +90,16 @@ describe('Agents page status refresh', () => {
beforeEach(() => {
vi.clearAllMocks();
gatewayState.status = { state: 'running', port: 18789 };
+ agentsState.agents = [];
+ agentsState.defaultModelRef = null;
+ providersState.accounts = [];
+ providersState.statuses = [];
+ providersState.vendors = [];
+ providersState.defaultAccountId = '';
fetchAgentsMock.mockResolvedValue(undefined);
+ updateAgentMock.mockResolvedValue(undefined);
+ updateAgentModelMock.mockResolvedValue(undefined);
+ refreshProviderSnapshotMock.mockResolvedValue(undefined);
hostApiFetchMock.mockResolvedValue({
success: true,
channels: [],
@@ -118,4 +153,65 @@ describe('Agents page status refresh', () => {
expect(channelFetchCalls).toHaveLength(2);
});
});
+
+ it('uses "Use default model" as form fill only and disables it when already default', async () => {
+ agentsState.agents = [
+ {
+ id: 'main',
+ name: 'Main',
+ isDefault: true,
+ modelDisplay: 'claude-opus-4.6',
+ modelRef: 'openrouter/anthropic/claude-opus-4.6',
+ overrideModelRef: null,
+ inheritedModel: true,
+ workspace: '~/.openclaw/workspace',
+ agentDir: '~/.openclaw/agents/main/agent',
+ mainSessionKey: 'agent:main:desk',
+ channelTypes: [],
+ },
+ ];
+ agentsState.defaultModelRef = 'openrouter/anthropic/claude-opus-4.6';
+ providersState.accounts = [
+ {
+ id: 'openrouter-default',
+ label: 'OpenRouter',
+ vendorId: 'openrouter',
+ authMode: 'api_key',
+ model: 'openrouter/anthropic/claude-opus-4.6',
+ enabled: true,
+ createdAt: '2026-03-24T00:00:00.000Z',
+ updatedAt: '2026-03-24T00:00:00.000Z',
+ },
+ ];
+ providersState.statuses = [{ id: 'openrouter-default', hasKey: true }];
+ providersState.vendors = [
+ { id: 'openrouter', name: 'OpenRouter', modelIdPlaceholder: 'anthropic/claude-opus-4.6' },
+ ];
+ providersState.defaultAccountId = 'openrouter-default';
+
+ render();
+
+ await waitFor(() => {
+ expect(fetchAgentsMock).toHaveBeenCalledTimes(1);
+ });
+
+ fireEvent.click(screen.getByTitle('settings'));
+ fireEvent.click(screen.getByText('settingsDialog.modelLabel').closest('button') as HTMLButtonElement);
+
+ const useDefaultButton = await screen.findByRole('button', { name: 'settingsDialog.useDefaultModel' });
+ const modelIdInput = screen.getByLabelText('settingsDialog.modelIdLabel');
+ const saveButton = screen.getByRole('button', { name: 'common:actions.save' });
+
+ expect(useDefaultButton).toBeDisabled();
+
+ fireEvent.change(modelIdInput, { target: { value: 'anthropic/claude-sonnet-4.5' } });
+ expect(useDefaultButton).toBeEnabled();
+ expect(saveButton).toBeEnabled();
+
+ fireEvent.click(useDefaultButton);
+
+ expect(updateAgentModelMock).not.toHaveBeenCalled();
+ expect((modelIdInput as HTMLInputElement).value).toBe('anthropic/claude-opus-4.6');
+ expect(useDefaultButton).toBeDisabled();
+ });
});
diff --git a/tests/unit/provider-runtime-sync.test.ts b/tests/unit/provider-runtime-sync.test.ts
index 1e6fa4094..772b3bcae 100644
--- a/tests/unit/provider-runtime-sync.test.ts
+++ b/tests/unit/provider-runtime-sync.test.ts
@@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({
setOpenClawDefaultModelWithOverride: vi.fn(),
syncProviderConfigToOpenClaw: vi.fn(),
updateAgentModelProvider: vi.fn(),
+ updateSingleAgentModelProvider: vi.fn(),
+ listAgentsSnapshot: vi.fn(),
}));
vi.mock('@electron/services/providers/provider-store', () => ({
@@ -50,6 +52,11 @@ vi.mock('@electron/utils/openclaw-auth', () => ({
setOpenClawDefaultModelWithOverride: mocks.setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw: mocks.syncProviderConfigToOpenClaw,
updateAgentModelProvider: mocks.updateAgentModelProvider,
+ updateSingleAgentModelProvider: mocks.updateSingleAgentModelProvider,
+}));
+
+vi.mock('@electron/utils/agent-config', () => ({
+ listAgentsSnapshot: mocks.listAgentsSnapshot,
}));
vi.mock('@electron/utils/logger', () => ({
@@ -62,6 +69,7 @@ vi.mock('@electron/utils/logger', () => ({
}));
import {
+ syncAgentModelOverrideToRuntime,
syncDefaultProviderToRuntime,
syncDeletedProviderToRuntime,
syncSavedProviderToRuntime,
@@ -109,6 +117,8 @@ describe('provider-runtime-sync refresh strategy', () => {
mocks.saveProviderKeyToOpenClaw.mockResolvedValue(undefined);
mocks.removeProviderFromOpenClaw.mockResolvedValue(undefined);
mocks.updateAgentModelProvider.mockResolvedValue(undefined);
+ mocks.updateSingleAgentModelProvider.mockResolvedValue(undefined);
+ mocks.listAgentsSnapshot.mockResolvedValue({ agents: [] });
});
it('uses debouncedReload after saving provider config', async () => {
@@ -142,4 +152,48 @@ describe('provider-runtime-sync refresh strategy', () => {
expect(gateway.debouncedReload).not.toHaveBeenCalled();
expect(gateway.debouncedRestart).not.toHaveBeenCalled();
});
+
+ it('syncs a targeted agent model override to runtime provider registry', async () => {
+ mocks.getAllProviders.mockResolvedValue([
+ createProvider({
+ id: 'ark',
+ type: 'ark',
+ model: 'doubao-pro',
+ }),
+ ]);
+ mocks.getProviderConfig.mockImplementation((providerType: string) => {
+ if (providerType === 'ark') {
+ return {
+ api: 'openai-completions',
+ baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
+ apiKeyEnv: 'ARK_API_KEY',
+ };
+ }
+ return {
+ api: 'openai-completions',
+ baseUrl: 'https://api.moonshot.cn/v1',
+ apiKeyEnv: 'MOONSHOT_API_KEY',
+ };
+ });
+ mocks.listAgentsSnapshot.mockResolvedValue({
+ agents: [
+ {
+ id: 'coder',
+ modelRef: 'ark/ark-code-latest',
+ },
+ ],
+ });
+
+ await syncAgentModelOverrideToRuntime('coder');
+
+ expect(mocks.updateSingleAgentModelProvider).toHaveBeenCalledWith(
+ 'coder',
+ 'ark',
+ expect.objectContaining({
+ baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
+ api: 'openai-completions',
+ models: [{ id: 'ark-code-latest', name: 'ark-code-latest' }],
+ }),
+ );
+ });
});