diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs index b2a18826e..bf1f413fa 100644 --- a/src-tauri/src/proxy/model_mapper.rs +++ b/src-tauri/src/proxy/model_mapper.rs @@ -12,6 +12,7 @@ pub struct ModelMapping { pub sonnet_model: Option, pub opus_model: Option, pub fable_model: Option, + pub subagent_model: Option, pub default_model: Option, } @@ -41,6 +42,11 @@ impl ModelMapping { .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(String::from), + subagent_model: env + .and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), default_model: env .and_then(|e| e.get("ANTHROPIC_MODEL")) .and_then(|v| v.as_str()) @@ -55,6 +61,7 @@ impl ModelMapping { || self.sonnet_model.is_some() || self.opus_model.is_some() || self.fable_model.is_some() + || self.subagent_model.is_some() || self.default_model.is_some() } @@ -89,6 +96,13 @@ impl ModelMapping { } } + if let Some(ref m) = self.subagent_model { + if strip_one_m_suffix_for_upstream(original_model) == strip_one_m_suffix_for_upstream(m) + { + return original_model.to_string(); + } + } + // 2. 默认模型 if let Some(ref m) = self.default_model { return m.clone(); @@ -327,6 +341,42 @@ mod tests { assert_eq!(mapped, Some("default-model".to_string())); } + #[test] + fn test_subagent_model_preserved_before_default_fallback() { + let mut provider = create_provider_with_mapping(); + provider.settings_config = json!({ + "env": { + "ANTHROPIC_MODEL": "default-model", + "CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini" + } + }); + + let body = json!({"model": "gpt-5.4-mini"}); + let (result, original, mapped) = apply_model_mapping(body, &provider); + + assert_eq!(result["model"], "gpt-5.4-mini"); + assert_eq!(original, Some("gpt-5.4-mini".to_string())); + assert!(mapped.is_none()); + } + + #[test] + fn test_subagent_model_preserved_with_one_m_suffix_before_default_fallback() { + let mut provider = create_provider_with_mapping(); + provider.settings_config = json!({ + "env": { + "ANTHROPIC_MODEL": "default-model", + "CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini" + } + }); + + let body = json!({"model": "gpt-5.4-mini[1M]"}); + let (result, original, mapped) = apply_model_mapping(body, &provider); + + assert_eq!(result["model"], "gpt-5.4-mini[1M]"); + assert_eq!(original, Some("gpt-5.4-mini[1M]".to_string())); + assert!(mapped.is_none()); + } + #[test] fn test_no_mapping_configured() { let provider = create_provider_without_mapping(); diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index a884aae98..3e0bc62fd 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -672,6 +672,7 @@ mod tests { "AWS_BEARER_TOKEN_BEDROCK": "bedrock-tok", "ANTHROPIC_BASE_URL": "https://example.com", "ANTHROPIC_MODEL": "claude-x", + "CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini", // 可共享、非机密配置(复数 _TOKENS 不应被误剥) "ENABLE_TOOL_SEARCH": "true", "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192" @@ -714,6 +715,9 @@ mod tests { // 端点/模型(provider-specific 非机密)也应剥掉 assert!(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")).is_none()); assert!(env.and_then(|e| e.get("ANTHROPIC_MODEL")).is_none()); + assert!(env + .and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL")) + .is_none()); // 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥) assert_eq!( @@ -2635,6 +2639,7 @@ impl ProviderService { "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + "CLAUDE_CODE_SUBAGENT_MODEL", "ANTHROPIC_BASE_URL", ]; diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index aa06bf459..72ef44b6f 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -26,7 +26,7 @@ const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED"; /// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名, /// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管, /// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。 -const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [ +const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 10] = [ "ANTHROPIC_MODEL", "ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留 "ANTHROPIC_DEFAULT_HAIKU_MODEL", @@ -35,8 +35,8 @@ const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [ "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", - // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型 - "ANTHROPIC_SMALL_FAST_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型 + "CLAUDE_CODE_SUBAGENT_MODEL", ]; const CLAUDE_TAKEOVER_HAIKU_MODEL: &str = "claude-haiku-4-5"; @@ -222,7 +222,9 @@ impl ProxyService { .or(default_model) .or(small_fast_model); - let mut fields = Vec::with_capacity(6); + let subagent_model = Self::claude_env_string(env, "CLAUDE_CODE_SUBAGENT_MODEL"); + + let mut fields = Vec::with_capacity(7); Self::push_claude_takeover_role_fields( &mut fields, env, @@ -250,6 +252,9 @@ impl ProxyService { true, opus_model, ); + if let Some(subagent_model) = subagent_model { + fields.push(("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.to_string())); + } fields } @@ -2857,7 +2862,8 @@ mod tests { "ANTHROPIC_MODEL": "claude-sonnet-4.6", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4.5", "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4.6", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-4.6" + "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-4.6", + "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4.6[1M]" } }), None, @@ -2877,7 +2883,8 @@ mod tests { "ANTHROPIC_DEFAULT_SONNET_MODEL": "stale-sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet", "ANTHROPIC_DEFAULT_OPUS_MODEL": "stale-opus", - "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus" + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus", + "CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent" } }); ProxyService::apply_claude_takeover_fields_for_provider( @@ -2917,10 +2924,53 @@ mod tests { "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", Some("claude-sonnet-4.6"), ); + assert_env_str( + env, + "CLAUDE_CODE_SUBAGENT_MODEL", + Some("claude-sonnet-4.6[1M]"), + ); assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None); } + #[test] + fn managed_account_claude_takeover_removes_stale_subagent_model_when_provider_omits_it() { + let mut provider = Provider::with_id( + "codex".to_string(), + "Codex".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "provider-sonnet" + } + }), + None, + ); + provider.meta = Some(ProviderMeta { + provider_type: Some("codex_oauth".to_string()), + ..Default::default() + }); + + let mut live_config = json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://stale.example.com", + "ANTHROPIC_API_KEY": "stale-key", + "CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent" + } + }); + ProxyService::apply_claude_takeover_fields_for_provider( + &mut live_config, + "http://127.0.0.1:15721", + &provider, + ); + + let env = live_config + .get("env") + .and_then(|value| value.as_object()) + .expect("env should exist"); + assert_env_str(env, "CLAUDE_CODE_SUBAGENT_MODEL", None); + } + #[test] fn managed_account_claude_takeover_sources_codex_models_from_provider() { let mut provider = Provider::with_id( @@ -4564,7 +4614,8 @@ model = "gpt-5.1-codex" "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "DeepSeek V4 Flash", "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1M]", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "DeepSeek V4 Pro", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]" + "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]", + "CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-pro[1M]" }, "permissions": { "allow": ["Read"] } }), @@ -4591,7 +4642,8 @@ model = "gpt-5.1-codex" "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721", "ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER, "ANTHROPIC_MODEL": "stale-model", - "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet" + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet", + "CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent" }, "permissions": { "allow": ["Bash"] } })) @@ -4674,6 +4726,13 @@ model = "gpt-5.1-codex" Some("deepseek-v4-ultra"), "implicit display names should strip the local 1M marker" ); + assert_eq!( + live_env + .get("CLAUDE_CODE_SUBAGENT_MODEL") + .and_then(|v| v.as_str()), + Some("deepseek-v4-pro[1M]"), + "subagent model should follow the target provider during hot switch" + ); let backup = db .get_live_backup("claude") diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 736593af5..7c341d3cc 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -126,6 +126,7 @@ interface ClaudeFormFieldsProps { defaultOpusModelName: string; defaultFableModel: string; defaultFableModelName: string; + subagentModel: string; onModelChange: (field: ClaudeModelEnvField, value: string) => void; // Speed Test Endpoints @@ -196,6 +197,7 @@ export function ClaudeFormFields({ defaultOpusModelName, defaultFableModel, defaultFableModelName, + subagentModel, onModelChange, speedTestEndpoints, apiFormat, @@ -221,6 +223,7 @@ export function ClaudeFormFields({ defaultSonnetModel || defaultOpusModel || defaultFableModel || + subagentModel || apiFormat !== "anthropic" || apiKeyField !== "ANTHROPIC_AUTH_TOKEN" || customUserAgent || @@ -513,12 +516,12 @@ export function ClaudeFormFields({ }; type ModelRoleRow = { - role: "sonnet" | "opus" | "fable" | "haiku"; + role: "sonnet" | "opus" | "fable" | "haiku" | "subagent"; label: string; model: string; - displayName: string; + displayName?: string; modelField: ClaudeModelEnvField; - displayNameField: ClaudeModelEnvField; + displayNameField?: ClaudeModelEnvField; inputId: string; supportsOneM: boolean; }; @@ -564,6 +567,16 @@ export function ClaudeFormFields({ inputId: "claudeDefaultHaikuModel", supportsOneM: false, }, + { + role: "subagent", + label: t("providerForm.modelRoleSubagent", { + defaultValue: "Subagent", + }), + model: subagentModel, + modelField: "CLAUDE_CODE_SUBAGENT_MODEL", + inputId: "claudeCodeSubagentModel", + supportsOneM: true, + }, ]; const handleRoleModelChange = (row: ModelRoleRow, value: string) => { @@ -572,10 +585,10 @@ export function ClaudeFormFields({ ? value : stripClaudeOneMMarker(value); const nextModelBase = stripClaudeOneMMarker(normalizedValue).trim(); - const displayName = row.displayName.trim(); + const displayName = row.displayName?.trim() ?? ""; const shouldSyncDisplayName = !displayName || displayName === oldModelBase; onModelChange(row.modelField, normalizedValue); - if (shouldSyncDisplayName) { + if (row.displayNameField && shouldSyncDisplayName) { onModelChange(row.displayNameField, nextModelBase); } }; @@ -815,17 +828,20 @@ export function ClaudeFormFields({ defaultSonnetModel || defaultOpusModel || defaultFableModel || - defaultHaikuModel; + defaultHaikuModel || + subagentModel; if (value) { for (const row of modelRoleRows) { const roleValue = row.supportsOneM ? value : stripClaudeOneMMarker(value); onModelChange(row.modelField, roleValue); - onModelChange( - row.displayNameField, - stripClaudeOneMMarker(roleValue), - ); + if (row.displayNameField) { + onModelChange( + row.displayNameField, + stripClaudeOneMMarker(roleValue), + ); + } } toast.success( t("providerForm.quickSetSuccess", { @@ -839,7 +855,8 @@ export function ClaudeFormFields({ !defaultHaikuModel && !defaultSonnetModel && !defaultOpusModel && - !defaultFableModel + !defaultFableModel && + !subagentModel } className="h-7 gap-1" > @@ -907,19 +924,30 @@ export function ClaudeFormFields({
{row.label}
- - onModelChange(row.displayNameField, event.target.value) - } - placeholder={ - modelBase || - t("providerForm.modelDisplayNamePlaceholder", { - defaultValue: "例如 DeepSeek V4 Pro", - }) - } - autoComplete="off" - /> + {row.displayNameField ? ( + + onModelChange( + row.displayNameField!, + event.target.value, + ) + } + placeholder={ + modelBase || + t("providerForm.modelDisplayNamePlaceholder", { + defaultValue: "例如 DeepSeek V4 Pro", + }) + } + autoComplete="off" + /> + ) : ( +
+ {t("providerForm.modelNoDisplayName", { + defaultValue: "不显示在 /model 菜单", + })} +
+ )} {renderModelInput( row.inputId, modelBase, diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index e9420724c..190ca58cd 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -489,6 +489,7 @@ function ProviderFormFull({ defaultOpusModelName, defaultFableModel, defaultFableModelName, + subagentModel, handleModelChange, } = useModelState({ settingsConfig: form.getValues("settingsConfig"), @@ -2099,6 +2100,7 @@ function ProviderFormFull({ defaultOpusModelName={defaultOpusModelName} defaultFableModel={defaultFableModel} defaultFableModelName={defaultFableModelName} + subagentModel={subagentModel} onModelChange={handleModelChange} speedTestEndpoints={speedTestEndpoints} apiFormat={localApiFormat} diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 302a4c8da..2454b2425 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -14,7 +14,8 @@ export type ClaudeModelEnvField = | "ANTHROPIC_DEFAULT_OPUS_MODEL" | "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME" | "ANTHROPIC_DEFAULT_FABLE_MODEL" - | "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME"; + | "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME" + | "CLAUDE_CODE_SUBAGENT_MODEL"; export const CLAUDE_ONE_M_MARKER = "[1M]"; @@ -81,6 +82,10 @@ function parseModelsFromConfig(settingsConfig: string) { typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME === "string" ? env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME : stripClaudeOneMMarker(fable); + const subagent = + typeof env.CLAUDE_CODE_SUBAGENT_MODEL === "string" + ? env.CLAUDE_CODE_SUBAGENT_MODEL + : ""; return { model, @@ -92,6 +97,7 @@ function parseModelsFromConfig(settingsConfig: string) { opusName, fable, fableName, + subagent, }; } catch { return { @@ -104,6 +110,7 @@ function parseModelsFromConfig(settingsConfig: string) { opusName: "", fable: "", fableName: "", + subagent: "", }; } } @@ -134,6 +141,7 @@ export function useModelState({ const [defaultFableModelName, setDefaultFableModelName] = useState( initial.fableName, ); + const [subagentModel, setSubagentModel] = useState(initial.subagent); const isUserEditingRef = useRef(false); const lastConfigRef = useRef(settingsConfig); @@ -164,6 +172,7 @@ export function useModelState({ setDefaultOpusModelName(parsed.opusName); setDefaultFableModel(parsed.fable); setDefaultFableModelName(parsed.fableName); + setSubagentModel(parsed.subagent); }, [settingsConfig]); const handleModelChange = useCallback( @@ -186,6 +195,7 @@ export function useModelState({ setDefaultFableModel(value); if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME") setDefaultFableModelName(value); + if (field === "CLAUDE_CODE_SUBAGENT_MODEL") setSubagentModel(value); try { const currentConfig = latestConfigRef.current @@ -233,6 +243,8 @@ export function useModelState({ setDefaultFableModel, defaultFableModelName, setDefaultFableModelName, + subagentModel, + setSubagentModel, handleModelChange, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index debf8bb5e..a020f5e1b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1124,8 +1124,10 @@ "modelRoleOpus": "Opus", "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", + "modelRoleSubagent": "Subagent", "modelDisplayNameLabel": "Display name", "modelDisplayNamePlaceholder": "e.g. DeepSeek V4 Pro", + "modelNoDisplayName": "Not shown in /model", "requestModelLabel": "Requested model", "modelOneMHeader": "Declare 1M", "modelOneMLabel": "1M", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 124005185..f3d0f6a1d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1124,8 +1124,10 @@ "modelRoleOpus": "Opus", "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", + "modelRoleSubagent": "Subagent", "modelDisplayNameLabel": "表示名", "modelDisplayNamePlaceholder": "例: DeepSeek V4 Pro", + "modelNoDisplayName": "/model には表示されません", "requestModelLabel": "リクエストモデル", "modelOneMHeader": "1M 対応を宣言", "modelOneMLabel": "1M", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 0e6650b92..db353195f 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1096,8 +1096,10 @@ "modelRoleOpus": "Opus", "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", + "modelRoleSubagent": "Subagent", "modelDisplayNameLabel": "顯示名稱", "modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro", + "modelNoDisplayName": "不顯示於 /model 選單", "requestModelLabel": "實際請求模型", "modelOneMHeader": "聲明支援 1M", "modelOneMLabel": "1M", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9c310a23b..81fabc019 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1124,8 +1124,10 @@ "modelRoleOpus": "Opus", "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", + "modelRoleSubagent": "Subagent", "modelDisplayNameLabel": "显示名称", "modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro", + "modelNoDisplayName": "不显示在 /model 菜单", "requestModelLabel": "实际请求模型", "modelOneMHeader": "声明支持 1M", "modelOneMLabel": "1M", diff --git a/tests/components/ClaudeFormFields.test.tsx b/tests/components/ClaudeFormFields.test.tsx index 8abb6f7e9..e6a9c8d5a 100644 --- a/tests/components/ClaudeFormFields.test.tsx +++ b/tests/components/ClaudeFormFields.test.tsx @@ -85,6 +85,7 @@ const renderCopilotForm = (overrides: Partial = {}) => { defaultOpusModelName: "", defaultFableModel: "", defaultFableModelName: "", + subagentModel: "", onModelChange: vi.fn(), speedTestEndpoints: [], apiFormat: "anthropic", @@ -173,4 +174,25 @@ describe("ClaudeFormFields", () => { ); }); }); + + it("一键设置会同时写入 Subagent 模型", () => { + const onModelChange = vi.fn(); + renderCopilotForm({ + claudeModel: "shared-model[1M]", + defaultSonnetModel: "", + defaultSonnetModelName: "", + onModelChange, + }); + + fireEvent.click( + screen.getByRole("button", { + name: "一键设置", + }), + ); + + expect(onModelChange).toHaveBeenCalledWith( + "CLAUDE_CODE_SUBAGENT_MODEL", + "shared-model[1M]", + ); + }); }); diff --git a/tests/hooks/useModelState.test.tsx b/tests/hooks/useModelState.test.tsx index c3cd0b504..0fafea433 100644 --- a/tests/hooks/useModelState.test.tsx +++ b/tests/hooks/useModelState.test.tsx @@ -17,6 +17,7 @@ describe("useModelState", () => { ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: "DeepSeek V4 Pro", ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2", ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: "Kimi K2", + CLAUDE_CODE_SUBAGENT_MODEL: "subagent-model[1M]", }, }); @@ -34,6 +35,7 @@ describe("useModelState", () => { expect(result.current.defaultOpusModelName).toBe("Kimi K2"); expect(result.current.defaultHaikuModel).toBe("legacy-small"); expect(result.current.defaultHaikuModelName).toBe("legacy-small"); + expect(result.current.subagentModel).toBe("subagent-model[1M]"); }); it("writes and clears role display-name env fields without changing model mapping", () => { @@ -94,6 +96,42 @@ describe("useModelState", () => { expect(result.current.defaultSonnetModelName).toBe("deepseek-v4-pro"); }); + it("writes and clears the Claude Code subagent model env field", () => { + let latestConfig = JSON.stringify({ + env: { + ANTHROPIC_MODEL: "fallback-model", + }, + }); + const onConfigChange = vi.fn((config: string) => { + latestConfig = config; + }); + + const { result } = renderHook(() => + useModelState({ + settingsConfig: latestConfig, + onConfigChange, + }), + ); + + act(() => { + result.current.handleModelChange( + "CLAUDE_CODE_SUBAGENT_MODEL", + "subagent-model[1M]", + ); + }); + + let env = JSON.parse(latestConfig).env; + expect(env.ANTHROPIC_MODEL).toBe("fallback-model"); + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("subagent-model[1M]"); + + act(() => { + result.current.handleModelChange("CLAUDE_CODE_SUBAGENT_MODEL", ""); + }); + + env = JSON.parse(latestConfig).env; + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBeUndefined(); + }); + it("normalizes Claude Code 1M markers for UI toggles", () => { expect(hasClaudeOneMMarker("deepseek-v4-pro[1m]")).toBe(true); expect(hasClaudeOneMMarker("deepseek-v4-pro [1M] ")).toBe(true);