import { http, delay, HttpResponse } from "msw"; import { WebClientConfig } from "#/api/option-service/option.types"; import type { SaveProfileRequest } from "#/api/profiles-service/profiles-service.api"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { Settings, SettingsValue } from "#/types/settings"; import { OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH, OPENAI_SUBSCRIPTION_DEVICE_START_PATH, OPENAI_SUBSCRIPTION_LOGOUT_PATH, OPENAI_SUBSCRIPTION_MODELS_PATH, OPENAI_SUBSCRIPTION_STATUS_PATH, OPENAI_SUBSCRIPTION_VENDOR, } from "#/constants/llm-subscription"; /** Simple recursive merge — objects merge, scalars overwrite. */ function deepMerge( base: Record, patch: Record, ): Record { const result = { ...base }; for (const [key, value] of Object.entries(patch)) { if ( value != null && typeof value === "object" && !Array.isArray(value) && typeof result[key] === "object" && result[key] != null && !Array.isArray(result[key]) ) { result[key] = deepMerge( result[key] as Record, value as Record, ); } else { result[key] = value; } } return result; } const DEFAULT_AGENT_SETTINGS = DEFAULT_SETTINGS.agent_settings ?? {}; const llmDefaults = (DEFAULT_AGENT_SETTINGS as Record).llm as | Record | undefined; const DEFAULT_MODEL = typeof llmDefaults?.model === "string" ? llmDefaults.model : "openhands/claude-opus-4-5-20251101"; export const createMockWebClientConfig = ( overrides: Partial = {}, ): WebClientConfig => ({ feature_flags: { hide_llm_settings: false, hide_users_page: false, ...overrides.feature_flags, }, providers_configured: [], maintenance_start_time: null, recaptcha_site_key: null, faulty_models: [], error_message: null, updated_at: new Date().toISOString(), ...overrides, }); const MOCK_AGENT_SETTINGS_SCHEMA: NonNullable< Settings["agent_settings_schema"] > = { model_name: "AgentSettings", sections: [ { key: "general", label: "General", fields: [ { key: "enable_sub_agents", label: "Enable sub-agents", description: "Allow the agent to delegate work to specialized built-in sub-agents.", section: "general", section_label: "General", value_type: "boolean", default: false, choices: [], depends_on: [], prominence: "major", secret: false, required: false, }, { key: "enable_switch_llm_tool", label: "Enable LLM switching tool", description: "Allow the agent to move the conversation to another saved LLM profile on its own.", section: "general", section_label: "General", value_type: "boolean", default: true, choices: [], depends_on: [], prominence: "major", secret: false, required: false, }, { key: "tool_concurrency_limit", label: "Parallel tool calls", description: "Maximum number of tool calls to execute concurrently per agent step. 1 = sequential (default).", section: "general", section_label: "General", value_type: "integer", default: 1, choices: [], depends_on: [], prominence: "major", secret: false, required: false, }, ], }, { key: "llm", label: "LLM", fields: [ { key: "llm.model", label: "Model", description: "Select the model to use for this conversation.", section: "llm", section_label: "LLM", value_type: "string", default: DEFAULT_MODEL, choices: [], depends_on: [], prominence: "critical", secret: false, required: true, }, { key: "llm.api_key", label: "API Key", description: "Provide the API key used to authenticate requests for the selected model.", section: "llm", section_label: "LLM", value_type: "string", default: null, choices: [], depends_on: [], prominence: "critical", secret: true, required: false, }, { key: "llm.base_url", description: "Override the model provider's default API base URL when needed.", label: "Base URL", section: "llm", section_label: "LLM", value_type: "string", default: null, choices: [], depends_on: [], prominence: "critical", secret: false, required: false, }, { key: "llm.temperature", label: "Temperature", description: "Adjust randomness for non-deterministic model outputs.", section: "llm", section_label: "LLM", value_type: "number", default: null, choices: [], depends_on: [], prominence: "minor", secret: false, required: false, }, ], }, { key: "verification", label: "Verification", fields: [ { key: "verification.critic_enabled", label: "Enable Critic", description: "Enable an additional critic pass to review the agent's work.", section: "verification", section_label: "Verification", value_type: "boolean", default: false, choices: [], depends_on: [], prominence: "critical", secret: false, required: false, }, { key: "verification.critic_mode", label: "Critic Mode", description: "Choose when the critic should review and intervene.", section: "verification", section_label: "Verification", value_type: "string", default: "finish_and_message", choices: [ { label: "finish_and_message", value: "finish_and_message", }, { label: "all_actions", value: "all_actions" }, ], depends_on: ["verification.critic_enabled"], prominence: "major", secret: false, required: false, }, { key: "verification.enable_iterative_refinement", label: "Enable Iterative Refinement", description: "Let the critic send the agent back to refine its work when issues are found.", section: "verification", section_label: "Verification", value_type: "boolean", default: false, choices: [], depends_on: ["verification.critic_enabled"], prominence: "critical", secret: false, required: false, }, // Rendered as a full-width row (see FIELD_FULL_WIDTH_KEYS) below the // two critical-prominence toggles so the input + OpenHands Cloud help // link have room to breathe. { key: "verification.critic_api_key", label: "Critic API Key", description: "If OpenHands is selected as your active LLM provider, leave this empty; the critic reuses the OpenHands Provider LLM Key.", section: "verification", section_label: "Verification", value_type: "string", default: null, choices: [], depends_on: ["verification.critic_enabled"], prominence: "critical", secret: true, required: false, }, { key: "verification.critic_threshold", label: "Critic Threshold", description: "Critic success threshold used for iterative refinement.", section: "verification", section_label: "Verification", value_type: "number", default: 0.6, choices: [], depends_on: [ "verification.critic_enabled", "verification.enable_iterative_refinement", ], prominence: "minor", secret: false, required: false, }, { key: "verification.max_refinement_iterations", label: "Max Refinement Iterations", description: "Maximum number of refinement attempts after critic feedback.", section: "verification", section_label: "Verification", value_type: "integer", default: 3, choices: [], depends_on: [ "verification.critic_enabled", "verification.enable_iterative_refinement", ], prominence: "minor", secret: false, required: false, }, { key: "verification.critic_server_url", label: "Critic Server URL", description: "Override the critic service URL.", section: "verification", section_label: "Verification", value_type: "string", default: null, choices: [], depends_on: ["verification.critic_enabled"], prominence: "minor", secret: false, required: false, }, { key: "verification.critic_model_name", label: "Critic Model Name", description: "Override the critic model name.", section: "verification", section_label: "Verification", value_type: "string", default: null, choices: [], depends_on: ["verification.critic_enabled"], prominence: "minor", secret: false, required: false, }, ], }, { key: "condenser", label: "Condenser", fields: [ { description: "Enable the default LLM-based condenser to summarize long conversation histories.", key: "condenser.enable_default_condenser", label: "Enable default condenser", section: "condenser", section_label: "Condenser", value_type: "boolean", default: true, choices: [], depends_on: [], prominence: "critical", secret: false, required: true, }, { description: "Maximum number of tokens the condenser keeps after summarization. Leave blank for unlimited.", key: "condenser.condenser_max_size", label: "Condenser max size", section: "condenser", section_label: "Condenser", value_type: "integer", default: null, choices: [], depends_on: [], prominence: "major", secret: false, required: false, }, ], }, { // Curated agent_context section (SDK fields_opt_in): only load_memory // is exposed, never the raw AgentContext model. ``label`` mirrors the // server's literal string ("Memory"); the GUI names the page itself via // the SCHEMA$AGENT_CONTEXT$SECTION_LABEL translation, which wins. key: "agent_context", label: "Memory", fields: [ { description: "The agent keeps notes under .openhands/memory/ and loads them at the start of each new conversation, learning your codebase and preferences over time.", key: "agent_context.load_memory", label: "Persistent memory", section: "agent_context", section_label: "Memory", value_type: "boolean", default: false, choices: [], depends_on: [], prominence: "major", secret: false, required: false, }, ], }, ], }; const MOCK_CONVERSATION_SETTINGS_SCHEMA: NonNullable< Settings["conversation_settings_schema"] > = { model_name: "ConversationSettings", sections: [ { key: "general", label: "General", fields: [ { key: "max_iterations", label: "Max iterations", section: "general", description: "Maximum number of agent steps allowed before the conversation stops.", section_label: "General", value_type: "integer", default: 500, choices: [], depends_on: [], prominence: "major", secret: false, required: true, }, ], }, { key: "verification", label: "Verification", fields: [ { key: "confirmation_mode", label: "Confirmation mode", description: "Pause for confirmation before the agent performs high-risk actions.", section: "verification", section_label: "Verification", value_type: "boolean", default: false, choices: [], depends_on: [], prominence: "major", secret: false, required: true, }, { key: "security_analyzer", label: "Security analyzer", description: "Choose how OpenHands should analyze actions before asking for confirmation.", section: "verification", section_label: "Verification", value_type: "string", default: "llm", choices: [ { label: "llm", value: "llm" }, { label: "none", value: "none" }, ], depends_on: ["confirmation_mode"], prominence: "major", secret: false, required: false, }, ], }, ], }; export const MOCK_DEFAULT_USER_SETTINGS: Settings = { ...DEFAULT_SETTINGS, provider_tokens_set: {}, agent_settings_schema: MOCK_AGENT_SETTINGS_SCHEMA, agent_settings: { ...DEFAULT_AGENT_SETTINGS, verification: { critic_enabled: false, enable_iterative_refinement: false, }, llm: { ...(llmDefaults ?? {}), api_key: null, model: DEFAULT_MODEL, }, condenser: { enable_default_condenser: true, condenser_max_size: null, }, enable_sub_agents: false, tool_concurrency_limit: 1, }, conversation_settings_schema: MOCK_CONVERSATION_SETTINGS_SCHEMA, conversation_settings: { ...(DEFAULT_SETTINGS.conversation_settings ?? {}), }, }; const MOCK_USER_PREFERENCES: { settings: Settings | null; } = { settings: structuredClone(MOCK_DEFAULT_USER_SETTINGS), }; interface MockLlmProfile { name: string; config: Record; api_key_set: boolean; } const MOCK_LLM_PROFILES: { profiles: Map; activeProfile: string | null; } = { profiles: new Map(), activeProfile: null, }; let mockOpenAISubscriptionConnected = false; const getProfileNameParam = (value: unknown): string => decodeURIComponent( Array.isArray(value) ? String(value[0] ?? "") : String(value ?? ""), ); const profileToListItem = (profile: MockLlmProfile) => ({ name: profile.name, model: typeof profile.config.model === "string" ? profile.config.model : null, base_url: typeof profile.config.base_url === "string" ? profile.config.base_url : null, api_key_set: profile.api_key_set, }); const applyProfileToMockSettings = (profile: MockLlmProfile) => { const current = MOCK_USER_PREFERENCES.settings || structuredClone(MOCK_DEFAULT_USER_SETTINGS); MOCK_USER_PREFERENCES.settings = { ...current, agent_settings: { ...(current.agent_settings ?? {}), llm: structuredClone(profile.config), }, llm_api_key_set: profile.api_key_set, }; }; const buildProfileDetail = ( profile: MockLlmProfile, exposeSecrets: string | null, ) => { const config = structuredClone(profile.config); if (profile.api_key_set && "api_key" in config) { if (exposeSecrets === "encrypted") { config.api_key = `gAAAAA_mock_encrypted_${profile.name}`; } else if (exposeSecrets === "plaintext") { // Keep the mock plaintext value. } else { config.api_key = null; } } return { name: profile.name, config, api_key_set: profile.api_key_set, }; }; const saveMockProfile = (name: string, request: SaveProfileRequest) => { const llm = structuredClone( (request.llm ?? {}) as Record, ); if (typeof llm.model !== "string" || llm.model.trim().length === 0) { return null; } const profile: MockLlmProfile = { name, config: llm, api_key_set: typeof llm.api_key === "string" && llm.api_key.trim().length > 0, }; MOCK_LLM_PROFILES.profiles.set(name, profile); if (MOCK_LLM_PROFILES.activeProfile === name) { applyProfileToMockSettings(profile); } return profile; }; export const resetTestHandlersMockSettings = () => { MOCK_USER_PREFERENCES.settings = structuredClone(MOCK_DEFAULT_USER_SETTINGS); MOCK_LLM_PROFILES.profiles.clear(); MOCK_LLM_PROFILES.activeProfile = null; mockOpenAISubscriptionConnected = false; }; // Mock model data used by provider/model endpoints const MOCK_MODELS = [ "anthropic/claude-3.5", "anthropic/claude-sonnet-4-20250514", "anthropic/claude-sonnet-4-5-20250929", "anthropic/claude-haiku-4-5-20251001", "anthropic/claude-opus-4-5-20251101", "anthropic/claude-opus-4-8", "openai/gpt-3.5-turbo", "openai/gpt-5.5", "openai/gpt-4o", "openai/gpt-4o-mini", "openhands/claude-sonnet-4-20250514", "openhands/claude-sonnet-4-5-20250929", "openhands/claude-haiku-4-5-20251001", "openhands/claude-opus-4-5-20251101", "openai/gpt-5.6-sol", "openai/gpt-6-astra", "openhands/deepseek-v4-flash", "openhands/glm-5.2", "sambanova/Meta-Llama-3.1-8B-Instruct", ]; const MOCK_OPENAI_SUBSCRIPTION_MODELS = ["gpt-5.2", "gpt-5.3-codex"]; const MOCK_VERIFIED_MODELS = new Set([ "anthropic/claude-opus-4-5-20251101", "anthropic/claude-opus-4-8", "anthropic/claude-sonnet-4-5-20250929", "openai/gpt-5.5", "openhands/claude-opus-4-5-20251101", "openhands/claude-sonnet-4-5-20250929", "openai/gpt-5.6-sol", "openai/gpt-6-astra", "openhands/deepseek-v4-flash", "openhands/glm-5.2", ]); // DB-driven free / default flags for the OpenHands provider. Mirrors the // enterprise verified-models seed used to render the "Free" badge and preselect // the default model. const MOCK_FREE_MODELS = new Set(["openhands/glm-5.2"]); const MOCK_DEFAULT_MODEL = "openhands/glm-5.2"; const MOCK_VERIFIED_PROVIDERS = [ "openhands", "anthropic", "openai", "mistral", "gemini", "deepseek", "moonshot", "minimax", ]; const MOCK_MODEL_PROVIDERS = Array.from( new Set( MOCK_MODELS.map((model) => model.split("/")[0]).filter( (provider): provider is string => Boolean(provider), ), ), ); const MOCK_VERIFIED_MODELS_BY_PROVIDER = MOCK_MODELS.reduce< Record >((acc, model) => { if (!MOCK_VERIFIED_MODELS.has(model)) return acc; const [provider, ...rest] = model.split("/"); if (!provider || rest.length === 0) return acc; acc[provider] ??= []; acc[provider].push(rest.join("/")); return acc; }, {}); // Matches the pinned `@openhands/typescript-client`, so mock mode models a // server that actually ships this schema. At 1.29.3 the mocked settings schema // advertised fields (`enable_switch_llm_tool`) that the mocked server's own // profile model would have rejected, and version-gated UI hid controls the // rest of the mocks were serving. const MOCK_AGENT_SERVER_VERSION = "1.36.1"; // --- Handlers for options/config/settings --- // Uses wildcard "*" prefix to match both relative paths and absolute URLs // (e.g., http://127.0.0.1:8000/api/...) since the code uses absolute URLs // when VITE_BACKEND_BASE_URL is configured. export const SETTINGS_HANDLERS = [ http.get("*/server_info", async () => HttpResponse.json({ uptime: 0, idle_time: 0, version: MOCK_AGENT_SERVER_VERSION, usable_tools: [ "terminal", "file_editor", "task_tracker", "browser_tool_set", ], agents: ["CodeActAgent"], default_agent: "CodeActAgent", models: MOCK_MODELS, security_analyzers: ["llm", "none"], }), ), http.get("*/api/llm/models", async () => HttpResponse.json({ models: MOCK_MODELS }), ), http.get("*/api/llm/models/verified", async () => HttpResponse.json({ models: MOCK_VERIFIED_MODELS_BY_PROVIDER }), ), http.get("*/api/llm/providers", async () => HttpResponse.json({ providers: MOCK_MODEL_PROVIDERS }), ), http.get(`*${OPENAI_SUBSCRIPTION_MODELS_PATH}`, async () => HttpResponse.json({ vendor: OPENAI_SUBSCRIPTION_VENDOR, models: MOCK_OPENAI_SUBSCRIPTION_MODELS, }), ), http.get(`*${OPENAI_SUBSCRIPTION_STATUS_PATH}`, async () => HttpResponse.json({ connected: mockOpenAISubscriptionConnected, account_email: mockOpenAISubscriptionConnected ? "mock-chatgpt@example.com" : null, expires_at: null, }), ), http.post(`*${OPENAI_SUBSCRIPTION_DEVICE_START_PATH}`, async () => HttpResponse.json({ device_code: "mock-device-code", user_code: "MOCK-CODE", verification_uri: "https://auth.openai.com/activate", verification_uri_complete: "https://auth.openai.com/activate?user_code=MOCK-CODE", interval: 1, expires_in: 900, }), ), http.post(`*${OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH}`, async () => { mockOpenAISubscriptionConnected = true; return HttpResponse.json({ connected: true, account_email: "mock-chatgpt@example.com", expires_at: null, }); }), http.post(`*${OPENAI_SUBSCRIPTION_LOGOUT_PATH}`, async () => { mockOpenAISubscriptionConnected = false; return HttpResponse.json({ connected: false }); }), // V0 (legacy) models endpoint – still used for default_model http.get("*/api/options/models", async () => HttpResponse.json({ models: MOCK_MODELS, verified_models: [ "claude-opus-4-5-20251101", "claude-sonnet-4-5-20250929", ], verified_providers: MOCK_VERIFIED_PROVIDERS, default_model: "openai/gpt-5.6-sol", }), ), // V1 providers search http.get("*/api/v1/config/providers/search", async ({ request }) => { const url = new URL(request.url); const query = url.searchParams.get("query")?.toLowerCase(); const verifiedEq = url.searchParams.get("verified__eq"); // Build unique provider list from models const seen = new Set(); let providers: { name: string; verified: boolean }[] = []; for (const model of MOCK_MODELS) { const [providerName] = model.split("/"); if (providerName && !seen.has(providerName)) { seen.add(providerName); providers.push({ name: providerName, verified: MOCK_VERIFIED_PROVIDERS.includes(providerName), }); } } if (query) { providers = providers.filter((p) => p.name.toLowerCase().includes(query)); } if (verifiedEq !== null && verifiedEq !== undefined) { const wantVerified = verifiedEq === "true"; providers = providers.filter((p) => p.verified === wantVerified); } return HttpResponse.json({ items: providers, next_page_id: null }); }), // V1 models search http.get("*/api/v1/config/models/search", async ({ request }) => { const url = new URL(request.url); const query = url.searchParams.get("query")?.toLowerCase(); const verifiedEq = url.searchParams.get("verified__eq"); const providerEq = url.searchParams.get("provider__eq"); let models = MOCK_MODELS.map((m) => { const [provider, ...rest] = m.split("/"); const name = rest.join("/"); return { provider: provider || null, name, verified: MOCK_VERIFIED_MODELS.has(m), free: MOCK_FREE_MODELS.has(m), default: m === MOCK_DEFAULT_MODEL, }; }); if (providerEq) { models = models.filter((m) => m.provider === providerEq); } if (query) { models = models.filter((m) => m.name.toLowerCase().includes(query)); } if (verifiedEq !== null && verifiedEq !== undefined) { const wantVerified = verifiedEq === "true"; models = models.filter((m) => m.verified === wantVerified); } return HttpResponse.json({ items: models, next_page_id: null }); }), http.get("*/api/options/security-analyzers", async () => HttpResponse.json(["llm", "none"]), ), http.get("*/api/profiles", async () => HttpResponse.json({ profiles: Array.from(MOCK_LLM_PROFILES.profiles.values()).map( profileToListItem, ), active_profile: MOCK_LLM_PROFILES.activeProfile, }), ), http.get("*/api/profiles/:name", async ({ params, request }) => { const name = getProfileNameParam(params.name); const profile = MOCK_LLM_PROFILES.profiles.get(name); if (!profile) { return HttpResponse.json( { detail: `Profile '${name}' not found` }, { status: 404 }, ); } return HttpResponse.json( buildProfileDetail(profile, request.headers.get("X-Expose-Secrets")), ); }), http.post("*/api/profiles/:name", async ({ params, request }) => { const name = getProfileNameParam(params.name); const body = (await request.json()) as SaveProfileRequest | null; if (!body) { return HttpResponse.json({ detail: "Empty body" }, { status: 400 }); } const profile = saveMockProfile(name, body); if (!profile) { return HttpResponse.json( { detail: "Profile requires llm.model" }, { status: 400 }, ); } return HttpResponse.json( { name: profile.name, message: `Profile '${profile.name}' saved` }, { status: 201 }, ); }), http.delete("*/api/profiles/:name", async ({ params }) => { const name = getProfileNameParam(params.name); MOCK_LLM_PROFILES.profiles.delete(name); if (MOCK_LLM_PROFILES.activeProfile === name) { MOCK_LLM_PROFILES.activeProfile = null; } return HttpResponse.json({ name, message: `Profile '${name}' deleted`, }); }), http.post("*/api/profiles/:name/rename", async ({ params, request }) => { const name = getProfileNameParam(params.name); const body = (await request.json()) as { new_name?: string } | null; const newName = body?.new_name?.trim() ?? ""; const profile = MOCK_LLM_PROFILES.profiles.get(name); if (!profile) { return HttpResponse.json( { detail: `Profile '${name}' not found` }, { status: 404 }, ); } if (!newName) { return HttpResponse.json( { detail: "new_name is required" }, { status: 400 }, ); } if (newName !== name && MOCK_LLM_PROFILES.profiles.has(newName)) { return HttpResponse.json( { detail: `Profile '${newName}' already exists` }, { status: 409 }, ); } MOCK_LLM_PROFILES.profiles.delete(name); const renamedProfile = { ...profile, name: newName }; MOCK_LLM_PROFILES.profiles.set(newName, renamedProfile); if (MOCK_LLM_PROFILES.activeProfile === name) { MOCK_LLM_PROFILES.activeProfile = newName; applyProfileToMockSettings(renamedProfile); } return HttpResponse.json({ name: newName, message: `Profile '${name}' renamed to '${newName}'`, }); }), http.post("*/api/profiles/:name/activate", async ({ params }) => { const name = getProfileNameParam(params.name); const profile = MOCK_LLM_PROFILES.profiles.get(name); if (!profile) { return HttpResponse.json( { detail: `Profile '${name}' not found` }, { status: 404 }, ); } MOCK_LLM_PROFILES.activeProfile = name; applyProfileToMockSettings(profile); return HttpResponse.json({ name, message: `Profile '${name}' activated and applied to current settings`, llm_applied: true, }); }), http.get("*/api/v1/web-client/config", () => { const config: WebClientConfig = { feature_flags: { hide_llm_settings: false, hide_users_page: false, }, providers_configured: [], maintenance_start_time: null, recaptcha_site_key: null, faulty_models: [], error_message: null, updated_at: new Date().toISOString(), }; return HttpResponse.json(config); }), http.get("*/api/settings/conversation-schema", async () => { await delay(); return HttpResponse.json(MOCK_CONVERSATION_SETTINGS_SCHEMA); }), http.get("*/api/v1/settings/conversation-schema", async () => { await delay(); return HttpResponse.json(MOCK_CONVERSATION_SETTINGS_SCHEMA); }), http.get("*/api/v1/settings", async () => { await delay(); const { settings } = MOCK_USER_PREFERENCES; if (!settings) return HttpResponse.json(null, { status: 404 }); return HttpResponse.json(settings); }), // New settings API endpoints (GET /api/settings with X-Expose-Secrets header support) http.get("*/api/settings", async ({ request }) => { // Exclude sub-paths like /api/settings/agent-schema (handled by separate handlers) const url = new URL(request.url); const pathParts = url.pathname.split("/").filter(Boolean); if (pathParts.length > 2) return undefined; await delay(); const { settings } = MOCK_USER_PREFERENCES; const DEFAULT_APP_PREFERENCES = { language: null, user_consents_to_analytics: null, enable_sound_notifications: null, git_user_name: null, git_user_email: null, title_llm_profile: null, disabled_skills: [], }; if (!settings) { return HttpResponse.json({ agent_settings: {}, conversation_settings: {}, llm_api_key_is_set: false, misc_settings: { app_preferences: DEFAULT_APP_PREFERENCES }, }); } const exposeSecrets = request.headers.get("X-Expose-Secrets"); // Build agent_settings, handling secrets based on header const agentSettings = structuredClone( settings.agent_settings ?? {}, ) as Record; const llm = agentSettings.llm as Record | undefined; if (llm?.api_key) { if (exposeSecrets === "encrypted") { // Return a mock "encrypted" value llm.api_key = `gAAAAA_mock_encrypted_${String(llm.api_key).slice(0, 8)}`; } else if (exposeSecrets === "plaintext") { // Keep as-is (plaintext) } else { // Redact llm.api_key = "**********"; } } const llmApiKeySet = !!settings.llm_api_key_set || (!!(settings.agent_settings as Record | undefined) ?.llm && !!( (settings.agent_settings as Record).llm as Record< string, unknown > )?.api_key); // Reuse the persisted misc_settings.app_preferences for repeat fetches, // but always fall back to the default-empty block so the GUI sees a // deterministic shape on first read. const storedMisc = (settings as Record).misc_settings as | { app_preferences?: Record } | undefined; const appPreferences = { ...DEFAULT_APP_PREFERENCES, ...(storedMisc?.app_preferences ?? {}), }; return HttpResponse.json({ agent_settings: agentSettings, conversation_settings: settings.conversation_settings ?? {}, llm_api_key_is_set: llmApiKeySet, misc_settings: { app_preferences: appPreferences }, }); }), // PATCH /api/settings - incremental updates http.patch("*/api/settings", async ({ request }) => { await delay(); const body = (await request.json()) as { agent_settings_diff?: Record; conversation_settings_diff?: Record; misc_settings_diff?: { app_preferences?: Record; }; } | null; if (!body) { return HttpResponse.json({ error: "Empty body" }, { status: 400 }); } if ( !body.agent_settings_diff && !body.conversation_settings_diff && !body.misc_settings_diff ) { return HttpResponse.json( { error: "At least one of agent_settings_diff, conversation_settings_diff, or misc_settings_diff must be provided", }, { status: 400 }, ); } const current = MOCK_USER_PREFERENCES.settings || structuredClone(MOCK_DEFAULT_USER_SETTINGS); const nextSettings: Settings = { ...current }; if (body.agent_settings_diff) { const merged = deepMerge( (current.agent_settings ?? {}) as Record, body.agent_settings_diff, ); nextSettings.agent_settings = merged as Settings["agent_settings"]; // Sync llm_api_key_set const llm = merged.llm as Record | undefined; if ( llm?.api_key && typeof llm.api_key === "string" && llm.api_key.trim().length > 0 ) { nextSettings.llm_api_key_set = true; } } if (body.conversation_settings_diff) { nextSettings.conversation_settings = { ...(current.conversation_settings ?? {}), ...body.conversation_settings_diff, }; } if (body.misc_settings_diff) { const existingMisc = (current as Record) .misc_settings as | { app_preferences?: Record } | undefined; // Deep-merge: nested `app_preferences` overlays field-by-field; // `disabled_skills` lists are replaced wholesale. This mirrors the // SDK's `_deep_merge` behaviour for the two-level shape currently // stored in `misc_settings`. const nextMisc: { app_preferences?: Record } = { ...(existingMisc ?? {}), }; if (body.misc_settings_diff.app_preferences) { nextMisc.app_preferences = { ...(existingMisc?.app_preferences ?? {}), ...body.misc_settings_diff.app_preferences, }; } (nextSettings as Record).misc_settings = nextMisc; } MOCK_USER_PREFERENCES.settings = nextSettings; // Return the updated settings (without secrets exposed) return HttpResponse.json({ agent_settings: nextSettings.agent_settings ?? {}, conversation_settings: nextSettings.conversation_settings ?? {}, llm_api_key_is_set: nextSettings.llm_api_key_set ?? false, misc_settings: ((nextSettings as Record) .misc_settings as | { app_preferences?: Record } | undefined) ?? { app_preferences: {} }, }); }), http.post("*/api/mcp/test", async () => { await delay(); return HttpResponse.json({ ok: true, tools: ["mock_tool"] }); }), http.get("*/api/settings/agent-schema", async () => { await delay(); return HttpResponse.json(MOCK_AGENT_SETTINGS_SCHEMA); }), http.get("*/api/v1/settings/agent-schema", async () => { await delay(); return HttpResponse.json(MOCK_AGENT_SETTINGS_SCHEMA); }), http.post("*/api/v1/settings", async ({ request }) => { await delay(); const body = (await request.json()) as Record | null; if (body) { const current = MOCK_USER_PREFERENCES.settings || structuredClone(MOCK_DEFAULT_USER_SETTINGS); if ("agent_settings" in body || "conversation_settings" in body) { return HttpResponse.json( { error: "Use *_diff nested settings payloads", keys: ["agent_settings", "conversation_settings"].filter( (key) => key in body, ), }, { status: 422 }, ); } const nextSettings: Settings = { ...current }; const agentSettingsPatch = body.agent_settings_diff as | Record | undefined; if (agentSettingsPatch) { const merged = deepMerge( (current.agent_settings ?? {}) as Record, agentSettingsPatch, ); nextSettings.agent_settings = merged as Settings["agent_settings"]; } const conversationSettingsPatch = body.conversation_settings_diff as | Record | undefined; if (conversationSettingsPatch) { nextSettings.conversation_settings = { ...(current.conversation_settings ?? {}), ...conversationSettingsPatch, }; } // Apply top-level fields (excluding nested settings) for (const [key, value] of Object.entries(body)) { if ( key !== "agent_settings_diff" && key !== "conversation_settings_diff" && key !== "agent_settings_schema" && key !== "conversation_settings_schema" ) { (nextSettings as Record)[key] = value; } } MOCK_USER_PREFERENCES.settings = nextSettings; return HttpResponse.json(null, { status: 200 }); } return HttpResponse.json(null, { status: 400 }); }), // POST /api/mcp/test – MCP server connectivity check before install. // Returns ok:true so the install modal can proceed to save and close. http.post("*/api/mcp/test", async () => { await delay(); return HttpResponse.json({ ok: true, tools: [] }); }), ];