/** * Scenario: Webview state crosses the VS Code bridge during settings changes, MCP edits, and chat failures. * Responsibilities: model metadata and selections remain provider-aware; MCP edits stay lossless; chat errors recover visibly. * Wiring: the real Zustand store and MCP bridge; settings saves, toast, and the VS Code messaging API are the only replaced boundaries. * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/settings-store.test.ts */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MCP_SECRET_MASK } from "../shared/legacy-sdk"; const boundary = vi.hoisted(() => ({ saveConfig: vi.fn(), streamChat: vi.fn(), abortChat: vi.fn(), trackFiles: vi.fn(), toastError: vi.fn(), toastWarning: vi.fn(), })); vi.mock("@/services", () => ({ bridge: { saveConfig: boundary.saveConfig, streamChat: boundary.streamChat, abortChat: boundary.abortChat, trackFiles: boundary.trackFiles, }, })); vi.mock("@/components/ui/sonner", () => ({ toast: { error: boundary.toastError, warning: boundary.toastWarning }, })); import { getMediaFallbackModel, getModelThinkingMode, groupModelsByProvider, requiresManagedProviderLogin, useSettingsStore, } from "../webview-ui/src/stores/settings.store"; import { useChatStore } from "../webview-ui/src/stores/chat.store"; const MODELS = [ { id: "plain", name: "Plain", provider: "managed:kimi-code", capabilities: [] }, { id: "reasoning", name: "Reasoning", provider: "managed:kimi-code", capabilities: ["thinking"], support_efforts: ["low", "high"], default_effort: "high", }, { id: "always", name: "Always", provider: "managed:kimi-code", capabilities: ["always_thinking"] }, ]; beforeEach(() => { boundary.saveConfig.mockReset(); boundary.streamChat.mockReset(); boundary.streamChat.mockResolvedValue({ done: false }); boundary.abortChat.mockReset(); boundary.abortChat.mockResolvedValue({ aborted: true }); boundary.trackFiles.mockReset(); boundary.toastError.mockReset(); boundary.toastWarning.mockReset(); useSettingsStore.getState().initModels(MODELS, "plain", false); useChatStore.setState({ sessionId: null, messages: [], isStreaming: false, isCompacting: false, handshakeReceived: false, draftMedia: [], lastStatus: null, tokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 }, activeTokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 }, pendingInput: null, queue: [], pendingQuestion: null, planMode: false, }); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("Webview model settings persistence", () => { it("persists the selected alias when display names collide across providers", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels([ { id: "openai/shared", name: "Shared", provider: "openai", capabilities: [] }, { id: "proxy/shared", name: "Shared", provider: "company-proxy", capabilities: [] }, ], "openai/shared", false); useSettingsStore.getState().updateModel("proxy/shared"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "proxy/shared", thinking: false, effort: "off", effortChanged: false, }); }); it("rolls back the optimistic model selection when saving fails", async () => { let rejectSave!: (error: Error) => void; boundary.saveConfig.mockReturnValue(new Promise((_resolve, reject) => { rejectSave = reject; })); useSettingsStore.getState().updateModel("reasoning"); expect(useSettingsStore.getState()).toMatchObject({ currentModel: "reasoning", thinkingEffort: "off", }); rejectSave(new Error("config.toml is read-only")); await vi.waitFor(() => { expect(useSettingsStore.getState()).toMatchObject({ currentModel: "plain", thinkingEffort: "off", }); }); expect(boundary.toastError).toHaveBeenCalledWith( "Failed to save model settings: config.toml is read-only", ); }); it("does not let an older failed save overwrite a newer selection", async () => { let rejectFirst!: (error: Error) => void; boundary.saveConfig .mockReturnValueOnce(new Promise((_resolve, reject) => { rejectFirst = reject; })) .mockResolvedValueOnce({ ok: true }); useSettingsStore.getState().updateModel("reasoning"); useSettingsStore.getState().updateModel("always"); rejectFirst(new Error("older request failed")); await Promise.resolve(); expect(useSettingsStore.getState().currentModel).toBe("always"); expect(boundary.toastError).not.toHaveBeenCalled(); }); }); describe("Webview model metadata", () => { it("keeps same-named models in separate provider groups", () => { const groups = groupModelsByProvider([ { id: "kimi/shared", name: "Shared", provider: "managed:kimi-code", capabilities: [] }, { id: "proxy/shared", name: "Shared", provider: "company-proxy", capabilities: [] }, ]); expect(groups.map((group) => ({ provider: group.provider, label: group.label, models: group.models.map((model) => model.id), }))).toEqual([ { provider: "company-proxy", label: "company-proxy", models: ["proxy/shared"] }, { provider: "managed:kimi-code", label: "Kimi Code", models: ["kimi/shared"] }, ]); }); it("offers a thinking toggle when a model declares adaptive thinking", () => { expect(getModelThinkingMode({ id: "anthropic/claude", name: "Claude", provider: "anthropic", capabilities: [], adaptive_thinking: true, })).toBe("switch"); }); it("prefers a compatible model from the current provider for media fallback", () => { const current = { id: "openai/text", name: "Text", provider: "openai", capabilities: [], }; const fallback = getMediaFallbackModel([ { id: "other/vision", name: "Vision A", provider: "other", capabilities: ["image_in"] }, { id: "openai/vision", name: "Vision B", provider: "openai", capabilities: ["image_in"] }, ], current); expect(fallback?.id).toBe("openai/vision"); }); it("does not require Kimi login when the default model uses a custom provider", () => { expect(requiresManagedProviderLogin([ { id: "local/model", name: "Local", provider: "local", capabilities: [] }, ], "local/model", false)).toBe(false); }); it("requires Kimi login when the default model uses the managed provider", () => { expect(requiresManagedProviderLogin([ { id: "kimi/model", name: "Kimi", provider: "managed:kimi-code", capabilities: [] }, ], "kimi/model", false)).toBe(true); }); }); describe("Webview MCP update bridge", () => { it("sends a lossless structured MCP edit request to the extension host", async () => { const posted: unknown[] = []; let receiveMessage: ((event: { data: unknown }) => void) | undefined; vi.stubGlobal("document", { body: { getAttribute: () => "mcp-test-view" }, }); vi.stubGlobal("window", { addEventListener: (_type: string, listener: (event: { data: unknown }) => void) => { receiveMessage = listener; }, }); vi.stubGlobal("acquireVsCodeApi", () => ({ postMessage: (message: { id: string }) => { posted.push(message); queueMicrotask(() => receiveMessage?.({ data: { id: message.id, result: [] } })); }, getState: () => undefined, setState: () => undefined, })); vi.resetModules(); const { bridge } = await import("../webview-ui/src/services/bridge"); await bridge.updateMCPServer("old-name", { name: "new-name", transport: "stdio", command: "C:\\Program Files\\Example MCP\\server.exe", args: ["--config", "C:\\Users\\Example User\\mcp config.json"], env: { SERVICE_TOKEN: MCP_SECRET_MASK, DEBUG: "1" }, }); expect(posted).toEqual([ expect.objectContaining({ method: "updateMCPServer", webviewId: "mcp-test-view", params: { originalName: "old-name", server: { name: "new-name", transport: "stdio", command: "C:\\Program Files\\Example MCP\\server.exe", args: ["--config", "C:\\Users\\Example User\\mcp config.json"], env: { SERVICE_TOKEN: MCP_SECRET_MASK, DEBUG: "1" }, }, }, }), ]); }); }); describe("Webview chat error recovery", () => { it("stops the pending state and keeps the input available when session setup fails", () => { useChatStore.getState().sendMessage("retry this request"); useChatStore.getState().processEvent({ type: "error", code: "session.state_invalid", message: "Session data is invalid.", detail: "state.json: Unexpected token at line 4", phase: "preflight", }); expect(useChatStore.getState()).toMatchObject({ isStreaming: false, isCompacting: false, pendingInput: { content: "retry this request", model: "plain" }, }); }); it("stops the response and retains provider detail when a running turn fails", () => { useChatStore.getState().sendMessage("start a turn"); useChatStore.getState().processEvent({ type: "TurnBegin", payload: { user_input: "start a turn" }, }); useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 1 } }); useChatStore.getState().processEvent({ type: "error", code: "provider.api_error", message: "Service temporarily unavailable.", detail: "HTTP 400: function name is invalid", phase: "runtime", }); expect(useChatStore.getState().isStreaming).toBe(false); expect(useChatStore.getState().messages.at(-1)?.inlineError).toEqual({ code: "provider.api_error", message: "Service temporarily unavailable.", detail: "HTTP 400: function name is invalid", }); }); }); describe("Webview thinking mode parity with the TUI", () => { it("derives thinking modes from metadata only, mirroring the TUI rules", () => { const base = { id: "m", name: "M", provider: "p", capabilities: [] as string[] }; expect(getModelThinkingMode({ ...base, capabilities: ["thinking"], support_efforts: ["low", "high"] })).toBe("effort"); expect(getModelThinkingMode({ ...base, capabilities: ["always_thinking"] })).toBe("always"); expect(getModelThinkingMode({ ...base, capabilities: ["thinking"] })).toBe("switch"); expect(getModelThinkingMode({ ...base, adaptive_thinking: true })).toBe("switch"); expect(getModelThinkingMode({ ...base, name: "Kimi Thinking Pro" })).toBe("none"); expect(getModelThinkingMode(base)).toBe("none"); }); }); describe("Webview thinking effort parity with the TUI", () => { it("resolves a boolean \"on\" to the model default for effort-capable models", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", false); useSettingsStore.getState().selectThinkingEffort("on"); expect(useSettingsStore.getState().thinkingEffort).toBe("high"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); }); it("prefers the persisted configured effort when resolving \"on\"", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", false); useSettingsStore.setState({ defaultThinkingEffort: "low" }); useSettingsStore.getState().selectThinkingEffort("on"); expect(useSettingsStore.getState().thinkingEffort).toBe("low"); }); it("keeps \"on\" for genuine boolean models", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels([ { id: "bool", name: "Bool", provider: "openai", capabilities: ["thinking"] }, ], "bool", false); useSettingsStore.getState().selectThinkingEffort("on"); expect(useSettingsStore.getState().thinkingEffort).toBe("on"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "bool", thinking: true, effort: "on" }); }); it("persists disabling thinking with thinking false", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", true); useSettingsStore.getState().selectThinkingEffort("off"); expect(useSettingsStore.getState().thinkingEffort).toBe("off"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: false, effort: "off" }); }); it("rejects \"off\" for always-on effort models", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels([ { id: "always-effort", name: "AE", provider: "openai", capabilities: ["always_thinking"], support_efforts: ["low", "high"] }, ], "always-effort", true); const previous = useSettingsStore.getState().thinkingEffort; boundary.saveConfig.mockClear(); useSettingsStore.getState().selectThinkingEffort("off"); expect(useSettingsStore.getState().thinkingEffort).toBe(previous); expect(boundary.saveConfig).not.toHaveBeenCalled(); }); it("rejects efforts outside support_efforts", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", false); const previous = useSettingsStore.getState().thinkingEffort; boundary.saveConfig.mockClear(); useSettingsStore.getState().selectThinkingEffort("ultra"); expect(useSettingsStore.getState().thinkingEffort).toBe(previous); expect(boundary.saveConfig).not.toHaveBeenCalled(); }); it("skips the config write when re-confirming the current effort", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", true); expect(useSettingsStore.getState().thinkingEffort).toBe("high"); boundary.saveConfig.mockClear(); useSettingsStore.getState().selectThinkingEffort("high"); expect(useSettingsStore.getState().thinkingEffort).toBe("high"); expect(boundary.saveConfig).not.toHaveBeenCalled(); }); it("seeds the top tier when it is the model's delivered default", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", false); useSettingsStore.getState().selectThinkingEffort("high"); expect(useSettingsStore.getState().thinkingEffort).toBe("high"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); expect(useSettingsStore.getState().defaultThinkingEffort).toBe("high"); }); it("does not seed a pick above the model's delivered default", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels([ { id: "reasoning", name: "Reasoning", provider: "managed:kimi-code", capabilities: ["thinking"], support_efforts: ["low", "high", "max"], default_effort: "low", }, ], "reasoning", false); useSettingsStore.getState().selectThinkingEffort("high"); expect(useSettingsStore.getState().thinkingEffort).toBe("high"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined(); }); it("does not seed the top tier when the model declares no default", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels([ { id: "reasoning", name: "Reasoning", provider: "managed:kimi-code", capabilities: ["thinking"], support_efforts: ["low", "high"], }, ], "reasoning", false); useSettingsStore.getState().selectThinkingEffort("high"); expect(useSettingsStore.getState().thinkingEffort).toBe("high"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined(); }); const SWITCH_MODELS = [ { id: "seeded", name: "Seeded", provider: "managed:kimi-code", capabilities: ["thinking"], support_efforts: ["low", "medium"], default_effort: "medium", }, { id: "max-default", name: "Max Default", provider: "managed:kimi-code", capabilities: ["thinking"], support_efforts: ["low", "max"], default_effort: "max", }, ]; it("updates the seed when a model switch persists the derived effort", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(SWITCH_MODELS, "seeded", true, "medium"); // "medium" is unsupported here, so the switch derives the model default // "max"; with the delivered default at the top tier the host persists it. useSettingsStore.getState().updateModel("max-default"); expect(useSettingsStore.getState().thinkingEffort).toBe("max"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "max-default", thinking: true, effort: "max", effortChanged: true, }); expect(useSettingsStore.getState().defaultThinkingEffort).toBe("max"); }); it("rolls the seed back when the model-switch save fails", async () => { let rejectSave!: (error: Error) => void; boundary.saveConfig.mockReturnValue(new Promise((_resolve, reject) => { rejectSave = reject; })); useSettingsStore.getState().initModels(SWITCH_MODELS, "seeded", true, "medium"); useSettingsStore.getState().updateModel("max-default"); expect(useSettingsStore.getState().defaultThinkingEffort).toBe("max"); rejectSave(new Error("config.toml is read-only")); await vi.waitFor(() => { expect(useSettingsStore.getState().defaultThinkingEffort).toBe("medium"); }); }); it("leaves the seed alone when the switch re-confirms the active effort", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); // No persisted effort: the seed starts undefined and the session derives // "max" from the model default. useSettingsStore.getState().initModels([ ...SWITCH_MODELS, { id: "max-default-b", name: "Max Default B", provider: "managed:kimi-code", capabilities: ["thinking"], support_efforts: ["low", "max"], default_effort: "max", }, ], "max-default", true); // The derived effort equals the active one, so the host leaves the stored // preference untouched — the seed must not invent one either. useSettingsStore.getState().updateModel("max-default-b"); expect(useSettingsStore.getState().thinkingEffort).toBe("max"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "max-default-b", thinking: true, effort: "max", effortChanged: false, }); expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined(); }); it("seeds future sessions with a persisted non-top effort", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", false); useSettingsStore.getState().selectThinkingEffort("low"); expect(useSettingsStore.getState().thinkingEffort).toBe("low"); expect(useSettingsStore.getState().defaultThinkingEffort).toBe("low"); }); it("marks the effort as changed when a model switch resolves to a different effort", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", true); expect(useSettingsStore.getState().thinkingEffort).toBe("high"); useSettingsStore.getState().updateModel("always"); expect(useSettingsStore.getState().thinkingEffort).toBe("on"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "always", thinking: true, effort: "on", effortChanged: true }); }); }); describe("Webview mid-turn warnings", () => { it("shows a non-terminal error as a toast without unlocking the composer", async () => { useChatStore.getState().sendMessage("first message"); useChatStore.getState().sendMessage("queued follow-up"); expect(useChatStore.getState().isStreaming).toBe(true); expect(useChatStore.getState().queue).toHaveLength(1); useChatStore.getState().processEvent({ type: "error", code: "internal", message: "Internal error occurred.", detail: "A response is already being generated for this session.", phase: "runtime", terminal: false, }); // The turn is still running: nothing unlocks, nothing flushes, nothing is retried. expect(boundary.toastWarning).toHaveBeenCalledWith("Internal error occurred."); const state = useChatStore.getState(); expect(state.isStreaming).toBe(true); expect(state.queue).toHaveLength(1); expect(state.pendingInput).not.toBeNull(); expect(state.messages.at(-1)?.inlineError).toBeUndefined(); // The genuine terminal still completes the turn and flushes the queue. useChatStore.getState().processEvent({ type: "stream_complete", result: { status: "finished" } }); expect(useChatStore.getState().isStreaming).toBe(false); await vi.waitFor(() => { expect(boundary.streamChat).toHaveBeenCalledTimes(2); }); }); });