/**
 * Tests for extensionsSlice — global catalog caches.
 */

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { createTestStore, type TestStore } from './storeTestUtils';

// Mock the renderer api module
vi.mock('../../../src/renderer/api', () => ({
  api: {
    plugins: {
      getAll: vi.fn(),
      getReadme: vi.fn(),
      install: vi.fn(),
      uninstall: vi.fn(),
    },
    mcpRegistry: {
      search: vi.fn(),
      browse: vi.fn(),
      getById: vi.fn(),
      getInstalled: vi.fn(),
      diagnose: vi.fn(),
      install: vi.fn(),
      installCustom: vi.fn(),
      uninstall: vi.fn(),
    },
    skills: {
      list: vi.fn(),
      getDetail: vi.fn(),
      previewUpsert: vi.fn(),
      applyUpsert: vi.fn(),
      previewImport: vi.fn(),
      applyImport: vi.fn(),
      deleteSkill: vi.fn(),
      startWatching: vi.fn(),
      stopWatching: vi.fn(),
      onChanged: vi.fn(),
    },
    apiKeys: {
      list: vi.fn(),
      save: vi.fn(),
      delete: vi.fn(),
      lookup: vi.fn(),
      getStorageStatus: vi.fn(),
    },
    cliInstaller: {
      getStatus: vi.fn(),
      getProviderStatus: vi.fn(),
      verifyProviderModels: vi.fn(),
      invalidateStatus: vi.fn(),
      onProgress: vi.fn(),
    },
  },
}));

import { api } from '../../../src/renderer/api';
import type { AppConfig, CliInstallationStatus } from '../../../src/shared/types';
import {
  getMcpDiagnosticKey,
  getMcpProjectStateKey,
  getMcpOperationKey,
  getPluginOperationKey,
} from '../../../src/shared/utils/extensionNormalizers';
import { createDefaultCliExtensionCapabilities } from '../../../src/shared/utils/providerExtensionCapabilities';

import type {
  EnrichedPlugin,
  McpCatalogItem,
  SkillCatalogItem,
  SkillDetail,
} from '../../../src/shared/types/extensions';

const makePlugin = (overrides: Partial<EnrichedPlugin>): EnrichedPlugin => ({
  pluginId: 'test@marketplace',
  marketplaceId: 'test@marketplace',
  qualifiedName: 'test@marketplace',
  name: 'Test Plugin',
  source: 'official',
  description: 'A test plugin',
  category: 'testing',
  hasLspServers: false,
  hasMcpServers: false,
  hasAgents: false,
  hasCommands: false,
  hasHooks: false,
  isExternal: false,
  installCount: 100,
  isInstalled: false,
  installations: [],
  ...overrides,
});

const makeMcpServer = (overrides: Partial<McpCatalogItem>): McpCatalogItem => ({
  id: 'test-server',
  name: 'Test Server',
  description: 'A test MCP server',
  source: 'official',
  installSpec: null,
  envVars: [],
  tools: [],
  requiresAuth: false,
  ...overrides,
});

const makeSkill = (overrides: Partial<SkillCatalogItem>): SkillCatalogItem => ({
  id: '/tmp/skills/demo',
  sourceType: 'filesystem',
  name: 'Demo Skill',
  description: 'Helps with demo work',
  folderName: 'demo',
  scope: 'user',
  rootKind: 'claude',
  projectRoot: null,
  discoveryRoot: '/tmp/skills',
  skillDir: '/tmp/skills/demo',
  skillFile: '/tmp/skills/demo/SKILL.md',
  metadata: {},
  invocationMode: 'auto',
  flags: {
    hasScripts: false,
    hasReferences: false,
    hasAssets: false,
  },
  isValid: true,
  issues: [],
  modifiedAt: 1,
  ...overrides,
});

const makeSkillDetail = (overrides: Partial<SkillDetail> = {}): SkillDetail => ({
  item: makeSkill({ id: '/tmp/skills/demo', skillDir: '/tmp/skills/demo' }),
  body: 'body',
  rawContent: '# Demo',
  rawFrontmatter: null,
  referencesFiles: [],
  scriptFiles: [],
  assetFiles: [],
  ...overrides,
});

const makeReadyCliStatus = (): CliInstallationStatus => ({
  flavor: 'claude' as const,
  displayName: 'Claude',
  supportsSelfUpdate: true,
  showVersionDetails: true,
  showBinaryPath: true,
  installed: true,
  installedVersion: '1.0.0',
  binaryPath: '/usr/local/bin/claude',
  latestVersion: '1.0.0',
  updateAvailable: false,
  authLoggedIn: true,
  authStatusChecking: false,
  authMethod: 'oauth_token' as const,
  providers: [],
});

const makeLimitedMultimodelCliStatus = (
  section: 'plugins' | 'mcp',
  reason: string
): CliInstallationStatus => ({
  flavor: 'agent_teams_orchestrator' as const,
  displayName: 'Claude Multimodel',
  supportsSelfUpdate: false,
  showVersionDetails: true,
  showBinaryPath: true,
  installed: true,
  installedVersion: '1.0.0',
  binaryPath: '/usr/local/bin/claude-multimodel',
  latestVersion: '1.0.0',
  updateAvailable: false,
  authLoggedIn: true,
  authStatusChecking: false,
  authMethod: null,
  providers: [
    {
      providerId: 'anthropic' as const,
      displayName: 'Anthropic',
      supported: true,
      authenticated: true,
      authMethod: 'oauth_token',
      verificationState: 'verified' as const,
      models: [],
      canLoginFromUi: true,
      capabilities: {
        teamLaunch: true,
        oneShot: true,
        extensions: createDefaultCliExtensionCapabilities({
          plugins: {
            status: section === 'plugins' ? 'unsupported' : 'supported',
            ownership: 'shared',
            reason: section === 'plugins' ? reason : null,
          },
          mcp: {
            status: section === 'mcp' ? 'read-only' : 'supported',
            ownership: 'shared',
            reason: section === 'mcp' ? reason : null,
          },
        }),
      },
      statusMessage: null,
      connection: null,
      backend: null,
    },
  ],
});

function makeAppConfig(multimodelEnabled: boolean): AppConfig {
  return {
    notifications: {
      enabled: true,
      soundEnabled: false,
      ignoredRegex: [],
      ignoredRepositories: [],
      snoozedUntil: null,
      snoozeMinutes: 60,
      includeSubagentErrors: true,
      notifyOnLeadInbox: true,
      notifyOnUserInbox: true,
      notifyOnClarifications: true,
      notifyOnStatusChange: true,
      notifyOnTaskComments: true,
      notifyOnTaskCreated: true,
      notifyOnAllTasksCompleted: true,
      notifyOnCrossTeamMessage: true,
      notifyOnTeamLaunched: true,
      notifyOnToolApproval: true,
      autoResumeOnRateLimit: false,
      statusChangeOnlySolo: false,
      statusChangeStatuses: [],
      triggers: [],
    },
    general: {
      launchAtLogin: false,
      showDockIcon: true,
      theme: 'system',
      defaultTab: 'dashboard',
      multimodelEnabled,
      claudeRootPath: null,
      agentLanguage: 'system',
      autoExpandAIGroups: true,
      useNativeTitleBar: false,
      telemetryEnabled: false,
    },
    providerConnections: {
      anthropic: {
        authMode: 'auto',
        fastModeDefault: false,
      },
      codex: {
        preferredAuthMode: 'auto',
      },
    },
    runtime: {
      providerBackends: {
        gemini: 'auto',
        codex: 'codex-native',
      },
    },
    display: {
      showTimestamps: true,
      compactMode: false,
      syntaxHighlighting: true,
    },
    sessions: {
      pinnedSessions: {},
      hiddenSessions: {},
    },
  };
}

const pluginOperationKey = (
  pluginId: string,
  scope: 'user' | 'project' | 'local' = 'user',
  projectPath?: string
) => getPluginOperationKey(pluginId, scope, projectPath);
const mcpOperationKey = (
  registryId: string,
  scope: 'user' | 'project' | 'local' | 'global' = 'user',
  projectPath?: string
) => getMcpOperationKey(registryId, scope, projectPath);

describe('extensionsSlice', () => {
  let store: TestStore;

  beforeEach(() => {
    store = createTestStore();
    vi.clearAllMocks();
    (api.cliInstaller!.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(makeReadyCliStatus());
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.restoreAllMocks();
  });

  describe('fetchPluginCatalog', () => {
    it('fetches and stores plugins', async () => {
      const plugins = [makePlugin({ pluginId: 'a@m' }), makePlugin({ pluginId: 'b@m' })];
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);

      await store.getState().fetchPluginCatalog();

      expect(store.getState().pluginCatalog).toHaveLength(2);
      expect(store.getState().pluginCatalogLoading).toBe(false);
      expect(store.getState().pluginCatalogError).toBeNull();
    });

    it('sets error on failure', async () => {
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));

      await store.getState().fetchPluginCatalog();

      expect(store.getState().pluginCatalog).toEqual([]);
      expect(store.getState().pluginCatalogError).toBe('boom');
      expect(store.getState().pluginCatalogLoading).toBe(false);
    });

    it('clears stale catalog when a different project fetch fails', async () => {
      store.setState({
        pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
        pluginCatalogProjectPath: '/tmp/project-a',
      });
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));

      await store.getState().fetchPluginCatalog('/tmp/project-b');

      expect(store.getState().pluginCatalog).toEqual([]);
      expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
      expect(store.getState().pluginCatalogError).toBe('boom');
    });

    it('clears plugin operation state when switching project context', async () => {
      store.setState({
        pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
        pluginCatalogProjectPath: '/tmp/project-a',
        pluginInstallProgress: {
          [pluginOperationKey('project-a@m', 'project')]: 'error',
        },
        installErrors: {
          [pluginOperationKey('project-a@m', 'project')]: 'Install failed',
          'mcp-server': 'Keep me',
        },
      });
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([
        makePlugin({ pluginId: 'project-b@m' }),
      ]);

      await store.getState().fetchPluginCatalog('/tmp/project-b');

      expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
      expect(
        store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')],
      ).toBeUndefined();
      expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined();
      expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
    });

    it('dedups concurrent requests for the same project key', async () => {
      let resolveFetch!: (plugins: EnrichedPlugin[]) => void;
      const inFlight = new Promise<EnrichedPlugin[]>((resolve) => {
        resolveFetch = resolve;
      });
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockImplementation(() => inFlight);

      const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a');
      const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-a');

      expect(api.plugins!.getAll).toHaveBeenCalledTimes(1);

      resolveFetch([makePlugin({ pluginId: 'same@m' })]);
      await Promise.all([firstFetch, secondFetch]);

      expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-a');
      expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual(['same@m']);
    });

    it('keeps the newest project catalog when project changes mid-flight', async () => {
      let resolveProjectA!: (plugins: EnrichedPlugin[]) => void;
      let resolveProjectB!: (plugins: EnrichedPlugin[]) => void;
      const projectAFetch = new Promise<EnrichedPlugin[]>((resolve) => {
        resolveProjectA = resolve;
      });
      const projectBFetch = new Promise<EnrichedPlugin[]>((resolve) => {
        resolveProjectB = resolve;
      });

      (api.plugins!.getAll as ReturnType<typeof vi.fn>)
        .mockImplementationOnce(() => projectAFetch)
        .mockImplementationOnce(() => projectBFetch);

      const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a');
      const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-b');

      expect(api.plugins!.getAll).toHaveBeenCalledTimes(2);

      resolveProjectB([makePlugin({ pluginId: 'project-b@m' })]);
      await secondFetch;

      expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
      expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([
        'project-b@m',
      ]);

      resolveProjectA([makePlugin({ pluginId: 'project-a@m' })]);
      await firstFetch;

      expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
      expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([
        'project-b@m',
      ]);
    });

    it('clears plugin operation state when a different project fetch fails', async () => {
      store.setState({
        pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
        pluginCatalogProjectPath: '/tmp/project-a',
        pluginInstallProgress: {
          [pluginOperationKey('project-a@m', 'project')]: 'error',
        },
        installErrors: {
          [pluginOperationKey('project-a@m', 'project')]: 'Install failed',
          'mcp-server': 'Keep me',
        },
      });
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));

      await store.getState().fetchPluginCatalog('/tmp/project-b');

      expect(store.getState().pluginCatalog).toEqual([]);
      expect(
        store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')],
      ).toBeUndefined();
      expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined();
      expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
    });
  });

  describe('fetchPluginReadme', () => {
    it('fetches and caches README', async () => {
      (api.plugins!.getReadme as ReturnType<typeof vi.fn>).mockResolvedValue('# Hello');

      store.getState().fetchPluginReadme('test@m');

      // Wait for the async to resolve
      await vi.waitFor(() => {
        expect(store.getState().pluginReadmes['test@m']).toBe('# Hello');
      });
      expect(store.getState().pluginReadmeLoading['test@m']).toBe(false);
    });

    it('does not re-fetch cached README', () => {
      store.setState({ pluginReadmes: { 'test@m': 'cached' } });

      store.getState().fetchPluginReadme('test@m');

      expect(api.plugins!.getReadme).not.toHaveBeenCalled();
    });

    it('retries README fetch when the cached value is null', () => {
      store.setState({ pluginReadmes: { 'test@m': null } });
      (api.plugins!.getReadme as ReturnType<typeof vi.fn>).mockResolvedValue(null);

      store.getState().fetchPluginReadme('test@m');

      expect(api.plugins!.getReadme).toHaveBeenCalledWith('test@m');
    });
  });

  describe('mcpBrowse', () => {
    it('fetches initial browse results', async () => {
      const servers = [makeMcpServer({ id: 's1' }), makeMcpServer({ id: 's2' })];
      (api.mcpRegistry!.browse as ReturnType<typeof vi.fn>).mockResolvedValue({
        servers,
        nextCursor: 'cursor-abc',
      });

      await store.getState().mcpBrowse();

      expect(store.getState().mcpBrowseCatalog).toHaveLength(2);
      expect(store.getState().mcpBrowseNextCursor).toBe('cursor-abc');
      expect(store.getState().mcpBrowseLoading).toBe(false);
    });

    it('appends on cursor-based pagination', async () => {
      store.setState({ mcpBrowseCatalog: [makeMcpServer({ id: 'existing' })] });
      const newServers = [makeMcpServer({ id: 'new1' })];
      (api.mcpRegistry!.browse as ReturnType<typeof vi.fn>).mockResolvedValue({
        servers: newServers,
        nextCursor: undefined,
      });

      await store.getState().mcpBrowse('cursor-1');

      expect(store.getState().mcpBrowseCatalog).toHaveLength(2);
      expect(store.getState().mcpBrowseNextCursor).toBeUndefined();
    });

    it('sets error on failure', async () => {
      (api.mcpRegistry!.browse as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('fail'));

      await store.getState().mcpBrowse();

      expect(store.getState().mcpBrowseError).toBe('fail');
      expect(store.getState().mcpBrowseLoading).toBe(false);
    });
  });

  describe('mcpFetchInstalled', () => {
    it('fetches installed MCP servers', async () => {
      const installed = [{ name: 'server-a', scope: 'user' as const }];
      (api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue(installed);

      await store.getState().mcpFetchInstalled();

      expect(store.getState().mcpInstalledServers).toEqual(installed);
    });

    it('stores installed MCP servers independently per project context', async () => {
      (api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>)
        .mockResolvedValueOnce([{ name: 'global-server', scope: 'global' as const }])
        .mockResolvedValueOnce([{ name: 'project-server', scope: 'project' as const }]);

      await store.getState().mcpFetchInstalled();
      await store.getState().mcpFetchInstalled('/tmp/project-a');

      expect(store.getState().mcpInstalledServersByProjectPath).toMatchObject({
        [getMcpProjectStateKey()]: [{ name: 'global-server', scope: 'global' }],
        [getMcpProjectStateKey('/tmp/project-a')]: [{ name: 'project-server', scope: 'project' }],
      });
    });

    it('clears stale project- and local-scoped MCP operation state when project changes', async () => {
      store.setState({
        mcpInstalledProjectPath: '/tmp/project-a',
        mcpInstallProgress: {
          [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'error',
          [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'success',
          [mcpOperationKey('user-server', 'user')]: 'pending',
        },
        installErrors: {
          [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'Project failed',
          [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'Local failed',
          [mcpOperationKey('user-server', 'user')]: 'Keep user state',
          'plugin:test@marketplace:user': 'Keep plugin state',
          'mcp-custom:custom-server:project:/tmp/project-a': 'Clear custom project state',
        },
      });
      (api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);

      await store.getState().mcpFetchInstalled('/tmp/project-b');

      expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b');
      expect(
        store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project', '/tmp/project-a')]
      ).toBeUndefined();
      expect(
        store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local', '/tmp/project-a')]
      ).toBeUndefined();
      expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe(
        'pending',
      );
      expect(
        store.getState().installErrors[mcpOperationKey('project-server', 'project', '/tmp/project-a')]
      ).toBeUndefined();
      expect(
        store.getState().installErrors[mcpOperationKey('local-server', 'local', '/tmp/project-a')]
      ).toBeUndefined();
      expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe(
        'Keep user state',
      );
      expect(store.getState().installErrors['mcp-custom:custom-server:project']).toBeUndefined();
      expect(store.getState().installErrors['plugin:test@marketplace:user']).toBe(
        'Keep plugin state',
      );
    });
  });

  describe('openExtensionsTab', () => {
    it('opens a new extensions tab', () => {
      // Ensure we have a focused pane
      expect(store.getState().paneLayout.panes.length).toBeGreaterThan(0);

      store.getState().openExtensionsTab();

      const tabs = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
      const extTab = tabs.find((t) => t.type === 'extensions');
      expect(extTab).toBeDefined();
      expect(extTab!.label).toBe('扩展');
    });

    it('seeds projectId from activeProjectId when selectedProjectId is null', () => {
      store.setState({ selectedProjectId: null, activeProjectId: 'project-active' });

      store.getState().openExtensionsTab();

      const tabs = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
      const extTab = tabs.find((t) => t.type === 'extensions');
      expect(extTab?.projectId).toBe('project-active');
    });

    it('activates existing extensions tab instead of creating new', () => {
      store.getState().openExtensionsTab();
      const tabs1 = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
      const count1 = tabs1.filter((t) => t.type === 'extensions').length;

      store.getState().openExtensionsTab();
      const tabs2 = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
      const count2 = tabs2.filter((t) => t.type === 'extensions').length;

      expect(count1).toBe(1);
      expect(count2).toBe(1); // no duplicate
    });

    it('updates projectId on existing tab when selected project changes', () => {
      // Open Extensions with project-A
      store.setState({ selectedProjectId: 'project-A', activeProjectId: null });
      store.getState().openExtensionsTab();

      const tabsBefore = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
      const extTabBefore = tabsBefore.find((t) => t.type === 'extensions');
      expect(extTabBefore?.projectId).toBe('project-A');

      // Switch to project-B and reopen Extensions
      store.setState({ selectedProjectId: 'project-B' });
      store.getState().openExtensionsTab();

      const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
      const extTabAfter = tabsAfter.find((t) => t.type === 'extensions');
      expect(extTabAfter?.projectId).toBe('project-B');
      // Still only one extensions tab
      expect(tabsAfter.filter((t) => t.type === 'extensions')).toHaveLength(1);
    });

    it('does not update projectId when it already matches', () => {
      store.setState({ selectedProjectId: 'project-A', activeProjectId: null });
      store.getState().openExtensionsTab();

      const layoutBefore = store.getState().paneLayout;

      // Reopen with same project — layout should be referentially stable (no set() call)
      store.getState().openExtensionsTab();

      const tabsBefore = layoutBefore.panes.flatMap((p) => p.tabs);
      const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
      const extBefore = tabsBefore.find((t) => t.type === 'extensions');
      const extAfter = tabsAfter.find((t) => t.type === 'extensions');
      expect(extAfter?.projectId).toBe(extBefore?.projectId);
    });
  });

  describe('installPlugin', () => {
    it('sets progress to pending then success', async () => {
      store.setState({ cliStatus: makeReadyCliStatus() });
      const plugins = [makePlugin({ pluginId: 'a@m' })];
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
      (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });

      // During execution, should be pending
      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending');

      await promise;
      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
    });

    it('does not block plugin install when a usable runtime status already exists during background refresh', async () => {
      store.setState({ cliStatus: makeReadyCliStatus(), cliStatusLoading: true });
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
      (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });

      expect(api.plugins!.install).toHaveBeenCalledWith({ pluginId: 'test@m', scope: 'user' });
      expect(api.cliInstaller!.getStatus).not.toHaveBeenCalled();
      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
    });

    it('sets progress to error on failure', async () => {
      store.setState({ cliStatus: makeReadyCliStatus() });
      (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
        state: 'error',
        error: 'Not found',
      });

      await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' });

      expect(store.getState().pluginInstallProgress[pluginOperationKey('fail@m')]).toBe('error');
    });

    it('fills missing projectPath from the active Extensions project context', async () => {
      store.setState({
        cliStatus: makeReadyCliStatus(),
        pluginCatalogProjectPath: '/tmp/project-a',
      });
      (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });

      expect(api.plugins!.install).toHaveBeenCalledWith({
        pluginId: 'project@m',
        scope: 'project',
        projectPath: '/tmp/project-a',
      });
    });

    it('keys project-scope install state by project path and refreshes that same project context', async () => {
      store.setState({
        cliStatus: makeReadyCliStatus(),
        pluginCatalogProjectPath: '/tmp/project-b',
      });
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
      (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      await store.getState().installPlugin({
        pluginId: 'project@m',
        scope: 'project',
        projectPath: '/tmp/project-a',
      });

      expect(
        store.getState().pluginInstallProgress[
          pluginOperationKey('project@m', 'project', '/tmp/project-a')
        ]
      ).toBe('success');
      expect(
        store.getState().pluginInstallProgress[
          pluginOperationKey('project@m', 'project', '/tmp/project-b')
        ]
      ).toBeUndefined();
      expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true);
    });

    it('fails fast for project scope when there is no active project path', async () => {
      store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });

      await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });

      expect(api.plugins!.install).not.toHaveBeenCalled();
      expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
        'error',
      );
      expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain(
        'active project',
      );
    });

    it('fails fast when multimodel runtime declares plugin installs unsupported', async () => {
      store.setState({
        cliStatus: makeLimitedMultimodelCliStatus('plugins', 'Plugin writes unavailable'),
      });

      await store.getState().installPlugin({ pluginId: 'unsupported@m', scope: 'user' });

      expect(api.plugins!.install).not.toHaveBeenCalled();
      expect(store.getState().pluginInstallProgress[pluginOperationKey('unsupported@m')]).toBe(
        'error',
      );
      expect(store.getState().installErrors[pluginOperationKey('unsupported@m')]).toContain(
        'Plugin writes unavailable',
      );
    });

    it('fills missing projectPath for local scope from the active Extensions project context', async () => {
      store.setState({
        cliStatus: makeReadyCliStatus(),
        pluginCatalogProjectPath: '/tmp/project-a',
      });
      (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' });

      expect(api.plugins!.install).toHaveBeenCalledWith({
        pluginId: 'local@m',
        scope: 'local',
        projectPath: '/tmp/project-a',
      });
    });

    it('fails fast for local scope when there is no active project path', async () => {
      store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });

      await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' });

      expect(api.plugins!.install).not.toHaveBeenCalled();
      expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
        'error',
      );
      expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain(
        'active project',
      );
    });

    it('keeps user-scope state isolated from local-scope failures', async () => {
      store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });

      await store.getState().installPlugin({ pluginId: 'shared@m', scope: 'local' });

      expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'local')]).toBe(
        'error',
      );
      expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'user')]).toBeUndefined();
      expect(store.getState().installErrors[pluginOperationKey('shared@m', 'user')]).toBeUndefined();
    });

    it('clears older success reset timers before a new operation on the same plugin', async () => {
      vi.useFakeTimers();
      store.setState({ cliStatus: makeReadyCliStatus() });
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
      (api.plugins!.install as ReturnType<typeof vi.fn>)
        .mockResolvedValueOnce({ state: 'success' })
        .mockResolvedValueOnce({ state: 'error', error: 'second failure' });

      await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');

      await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error');

      await vi.advanceTimersByTimeAsync(2_000);

      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error');
    });
  });

  describe('uninstallPlugin', () => {
    it('sets progress to pending then success', async () => {
      store.setState({ cliStatus: makeReadyCliStatus() });
      const plugins = [makePlugin({ pluginId: 'a@m', isInstalled: false })];
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
      (api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      const promise = store.getState().uninstallPlugin('test@m', 'user');

      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending');

      await promise;
      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
    });

    it('fills missing projectPath from the active Extensions project context', async () => {
      store.setState({ pluginCatalogProjectPath: '/tmp/project-a' });
      (api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      await store.getState().uninstallPlugin('project@m', 'project');

      expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a');
    });

    it('keys project-scope uninstall state by project path and refreshes that same project context', async () => {
      store.setState({ pluginCatalogProjectPath: '/tmp/project-b' });
      (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
      (api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      await store.getState().uninstallPlugin('project@m', 'project', '/tmp/project-a');

      expect(
        store.getState().pluginInstallProgress[
          pluginOperationKey('project@m', 'project', '/tmp/project-a')
        ]
      ).toBe('success');
      expect(
        store.getState().pluginInstallProgress[
          pluginOperationKey('project@m', 'project', '/tmp/project-b')
        ]
      ).toBeUndefined();
      expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true);
    });

    it('fails fast for project uninstall when there is no active project path', async () => {
      store.setState({ pluginCatalogProjectPath: null });

      await store.getState().uninstallPlugin('project@m', 'project');

      expect(api.plugins!.uninstall).not.toHaveBeenCalled();
      expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
        'error',
      );
      expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain(
        'active project',
      );
    });

    it('fills missing projectPath for local uninstall from the active Extensions project context', async () => {
      store.setState({ pluginCatalogProjectPath: '/tmp/project-a' });
      (api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      await store.getState().uninstallPlugin('local@m', 'local');

      expect(api.plugins!.uninstall).toHaveBeenCalledWith('local@m', 'local', '/tmp/project-a');
    });

    it('fails fast for local uninstall when there is no active project path', async () => {
      store.setState({ pluginCatalogProjectPath: null });

      await store.getState().uninstallPlugin('local@m', 'local');

      expect(api.plugins!.uninstall).not.toHaveBeenCalled();
      expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
        'error',
      );
      expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain(
        'active project',
      );
    });

    it('does not restore idle state after project switch clears a pending success timer', async () => {
      vi.useFakeTimers();
      store.setState({
        pluginCatalogProjectPath: '/tmp/project-a',
        pluginCatalog: [makePlugin({ pluginId: 'test@m' })],
      });
      (api.plugins!.getAll as ReturnType<typeof vi.fn>)
        .mockResolvedValueOnce([makePlugin({ pluginId: 'test@m' })])
        .mockResolvedValueOnce([makePlugin({ pluginId: 'other@m' })]);
      (api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });

      await store.getState().uninstallPlugin('test@m', 'user');
      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');

      await store.getState().fetchPluginCatalog('/tmp/project-b');
      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined();

      await vi.advanceTimersByTimeAsync(2_000);

      expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined();
    });
  });

  describe('installMcpServer', () => {
    it('sets progress to pending then success', async () => {
      store.setState({ cliStatus: makeReadyCliStatus() });
      (api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
      (api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
      (api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);

      const promise = store.getState().installMcpServer({
        registryId: 'test-id',
        serverName: 'test-server',
        scope: 'user',
        envValues: {},
        headers: [],
      });

      expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
        'pending',
      );

      await promise;
      expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
        'success',
      );
    });

    it('does not block MCP install when a usable runtime status already exists during background refresh', async () => {
      store.setState({ cliStatus: makeReadyCliStatus(), cliStatusLoading: true });
      (api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
      (api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
      (api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);

      await store.getState().installMcpServer({
        registryId: 'test-id',
        serverName: 'test-server',
        scope: 'user',
        envValues: {},
        headers: [],
      });

      expect(api.mcpRegistry!.install).toHaveBeenCalledWith({
        registryId: 'test-id',
        serverName: 'test-server',
        scope: 'user',
        envValues: {},
        headers: [],
      });
      expect(api.cliInstaller!.getStatus).not.toHaveBeenCalled();
      expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
        'success',
      );
    });

    it('does not restore idle state after project switch clears a pending project-scope success timer', async () => {
      vi.useFakeTimers();
      store.setState({
        mcpInstalledProjectPath: '/tmp/project-a',
      });
      (api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
      (api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
      (api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);

      await store.getState().installMcpServer({
        registryId: 'test-id',
        serverName: 'test-server',
        scope: 'project',
        projectPath: '/tmp/project-a',
        envValues: {},
        headers: [],
      });

      expect(
        store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')]
      ).toBe('success');

      await store.getState().mcpFetchInstalled('/tmp/project-b');
      expect(
        store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')]
      ).toBeUndefined();

      await vi.advanceTimersByTimeAsync(2_000);

      expect(
        store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')]
      ).toBeUndefined();
    });

    it('fails fast when multimodel runtime exposes MCP as read-only', async () => {
      store.setState({
        cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'),
      });

      await store.getState().installMcpServer({
        registryId: 'test-id',
        serverName: 'test-server',
        scope: 'global',
        envValues: {},
        headers: [],
      });

      expect(api.mcpRegistry!.install).not.toHaveBeenCalled();
      expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe(
        'error',
      );
      expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain(
        'MCP writes unavailable',
      );
    });
  });

  describe('installCustomMcpServer', () => {
    it('rejects and records an error when MCP writes are unavailable', async () => {
      store.setState({
        cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'),
      });

      await expect(
        store.getState().installCustomMcpServer({
          serverName: 'custom-server',
          scope: 'global',
          installSpec: {
            type: 'stdio',
            npmPackage: '@example/custom-mcp',
          },
          envValues: {},
          headers: [],
        }),
      ).rejects.toThrow('MCP writes unavailable');

      expect(api.mcpRegistry!.installCustom).not.toHaveBeenCalled();
      expect(store.getState().mcpInstallProgress['mcp-custom:custom-server:global']).toBe('error');
      expect(store.getState().installErrors['mcp-custom:custom-server:global']).toContain(
        'MCP writes unavailable',
      );
    });
  });

  describe('uninstallMcpServer', () => {
    it('sets progress to pending then success', async () => {
      store.setState({ cliStatus: makeReadyCliStatus() });
      (api.mcpRegistry!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
      (api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
      (api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);

      const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user');

      expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
        'pending',
      );

      await promise;
      expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
        'success',
      );
    });

    it('fails fast when multimodel runtime exposes MCP as read-only', async () => {
      store.setState({
        cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'),
      });

      await store.getState().uninstallMcpServer('test-id', 'test-server', 'global');

      expect(api.mcpRegistry!.uninstall).not.toHaveBeenCalled();
      expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe(
        'error',
      );
      expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain(
        'MCP writes unavailable',
      );
    });
  });

  describe('provider-aware runtime refresh', () => {
    it('passes projectPath through MCP diagnostics', async () => {
      (api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);

      await store.getState().runMcpDiagnostics('/tmp/project-a');

      expect(api.mcpRegistry!.diagnose).toHaveBeenCalledWith('/tmp/project-a');
    });

    it('refreshes CLI status after saving an API key', async () => {
      (api.apiKeys!.save as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'k1' });
      (api.apiKeys!.list as ReturnType<typeof vi.fn>).mockResolvedValue([
        {
          id: 'k1',
          name: 'Codex key',
          envVarName: 'OPENAI_API_KEY',
          maskedValue: '***',
          scope: 'user',
          createdAt: '2026-04-17T10:00:00.000Z',
        },
      ]);

      await store.getState().saveApiKey({
        name: 'Codex key',
        envVarName: 'OPENAI_API_KEY',
        value: 'secret',
        scope: 'user',
      });

      expect(api.cliInstaller!.getStatus).toHaveBeenCalled();
      expect(store.getState().apiKeys).toHaveLength(1);
    });

    it('keeps saved API keys updated when provider status refresh fails', async () => {
      store.setState({
        appConfig: makeAppConfig(false),
      });
      (api.apiKeys!.save as ReturnType<typeof vi.fn>).mockResolvedValue({
        id: 'k1',
        name: 'Codex key',
        envVarName: 'OPENAI_API_KEY',
        maskedValue: '***',
        scope: 'user',
        createdAt: '2026-04-17T10:00:00.000Z',
      });
      (api.apiKeys!.list as ReturnType<typeof vi.fn>).mockResolvedValue([
        {
          id: 'k1',
          name: 'Codex key',
          envVarName: 'OPENAI_API_KEY',
          maskedValue: '***',
          scope: 'user',
          createdAt: '2026-04-17T10:00:00.000Z',
        },
      ]);
      (api.cliInstaller!.getStatus as ReturnType<typeof vi.fn>).mockRejectedValue(
        new Error('refresh boom')
      );

      await store.getState().saveApiKey({
        name: 'Codex key',
        envVarName: 'OPENAI_API_KEY',
        value: 'secret',
        scope: 'user',
      });

      expect(store.getState().apiKeys).toHaveLength(1);
      expect(store.getState().apiKeysError).toContain('API key saved, but failed to refresh provider status.');
    });

    it('keeps local API key state updated when key list refresh fails after save', async () => {
      (api.apiKeys!.save as ReturnType<typeof vi.fn>).mockResolvedValue({
        id: 'k1',
        name: 'Codex key',
        envVarName: 'OPENAI_API_KEY',
        maskedValue: '***',
        scope: 'user',
        createdAt: '2026-04-17T10:00:00.000Z',
      });
      (api.apiKeys!.list as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('list boom'));

      await store.getState().saveApiKey({
        name: 'Codex key',
        envVarName: 'OPENAI_API_KEY',
        value: 'secret',
        scope: 'user',
      });

      expect(store.getState().apiKeys).toEqual([
        {
          id: 'k1',
          name: 'Codex key',
          envVarName: 'OPENAI_API_KEY',
          maskedValue: '***',
          scope: 'user',
          createdAt: '2026-04-17T10:00:00.000Z',
        },
      ]);
      expect(store.getState().apiKeysError).toContain('API key saved, but failed to refresh key list.');
    });

    it('refreshes CLI status after deleting an API key', async () => {
      store.setState({
        apiKeys: [
          {
            id: 'k1',
            name: 'Codex key',
            envVarName: 'OPENAI_API_KEY',
            maskedValue: '***',
            scope: 'user',
            createdAt: '2026-04-17T10:00:00.000Z',
          },
        ],
      });
      (api.apiKeys!.delete as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);

      await store.getState().deleteApiKey('k1');

      expect(api.cliInstaller!.getStatus).toHaveBeenCalled();
      expect(store.getState().apiKeys).toEqual([]);
    });

    it('keeps local API key state updated when provider status refresh fails after delete', async () => {
      store.setState({
        appConfig: makeAppConfig(false),
      });
      store.setState({
        apiKeys: [
          {
            id: 'k1',
            name: 'Codex key',
            envVarName: 'OPENAI_API_KEY',
            maskedValue: '***',
            scope: 'user',
            createdAt: '2026-04-17T10:00:00.000Z',
          },
        ],
      });
      (api.apiKeys!.delete as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
      (api.cliInstaller!.getStatus as ReturnType<typeof vi.fn>).mockRejectedValue(
        new Error('refresh boom')
      );

      await store.getState().deleteApiKey('k1');

      expect(store.getState().apiKeys).toEqual([]);
      expect(store.getState().apiKeysError).toContain(
        'API key deleted, but failed to refresh provider status.'
      );
    });

    it('keys MCP diagnostics by scope when the same server exists in multiple scopes', async () => {
      (api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([
        {
          name: 'context7',
          scope: 'global',
          target: 'npx -y @upstash/context7-mcp',
          status: 'connected',
          statusLabel: 'Connected',
          rawLine: 'context7: npx -y @upstash/context7-mcp - Connected',
          checkedAt: 1,
        },
        {
          name: 'context7',
          scope: 'project',
          target: 'uvx context7-project',
          status: 'failed',
          statusLabel: 'Failed to connect',
          rawLine: 'context7: uvx context7-project - Failed to connect',
          checkedAt: 1,
        },
      ]);

      await store.getState().runMcpDiagnostics('/tmp/project-a');

      expect(store.getState().mcpDiagnostics).toMatchObject({
        [getMcpDiagnosticKey('context7', 'global')]: expect.objectContaining({
          target: 'npx -y @upstash/context7-mcp',
        }),
        [getMcpDiagnosticKey('context7', 'project')]: expect.objectContaining({
          target: 'uvx context7-project',
        }),
      });
    });

    it('stores MCP diagnostics independently per project context', async () => {
      (api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>)
        .mockResolvedValueOnce([
          {
            name: 'global-server',
            scope: 'global',
            target: 'npx global-server',
            status: 'connected',
            statusLabel: 'Connected',
            rawLine: 'global-server: npx global-server - Connected',
            checkedAt: 1,
          },
        ])
        .mockResolvedValueOnce([
          {
            name: 'project-server',
            scope: 'project',
            target: 'uvx project-server',
            status: 'failed',
            statusLabel: 'Failed to connect',
            rawLine: 'project-server: uvx project-server - Failed to connect',
            checkedAt: 2,
          },
        ]);

      await store.getState().runMcpDiagnostics();
      await store.getState().runMcpDiagnostics('/tmp/project-a');

      expect(store.getState().mcpDiagnosticsByProjectPath).toMatchObject({
        [getMcpProjectStateKey()]: {
          [getMcpDiagnosticKey('global-server', 'global')]: expect.objectContaining({
            target: 'npx global-server',
          }),
        },
        [getMcpProjectStateKey('/tmp/project-a')]: {
          [getMcpDiagnosticKey('project-server', 'project')]: expect.objectContaining({
            target: 'uvx project-server',
          }),
        },
      });
    });

    it('refreshes MCP install state using the operation project context instead of the last viewed tab', async () => {
      store.setState({
        mcpInstalledProjectPath: '/tmp/project-b',
      });
      (api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
      (api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
      (api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);

      await store.getState().installMcpServer({
        registryId: 'test-id',
        serverName: 'test-server',
        scope: 'project',
        projectPath: '/tmp/project-a',
        envValues: {},
        headers: [],
      });

      expect(api.mcpRegistry!.getInstalled).toHaveBeenLastCalledWith('/tmp/project-a');
      expect(api.mcpRegistry!.diagnose).toHaveBeenLastCalledWith('/tmp/project-a');
    });
  });

  describe('skills state hardening', () => {
    it('ignores stale catalog responses for the same project key', async () => {
      let resolveFirst!: (value: SkillCatalogItem[]) => void;
      const firstPromise = new Promise<SkillCatalogItem[]>((resolve) => {
        resolveFirst = resolve;
      });
      const secondResult = [
        makeSkill({
          id: '/tmp/project/.claude/skills/newer',
          skillDir: '/tmp/project/.claude/skills/newer',
          skillFile: '/tmp/project/.claude/skills/newer/SKILL.md',
          scope: 'project',
          projectRoot: '/tmp/project',
          discoveryRoot: '/tmp/project/.claude/skills',
          name: 'Newer Skill',
        }),
      ];

      (api.skills!.list as ReturnType<typeof vi.fn>)
        .mockImplementationOnce(() => firstPromise)
        .mockResolvedValueOnce(secondResult);

      const firstFetch = store.getState().fetchSkillsCatalog('/tmp/project');
      const secondFetch = store.getState().fetchSkillsCatalog('/tmp/project');

      await secondFetch;
      resolveFirst([
        makeSkill({
          id: '/tmp/project/.claude/skills/older',
          skillDir: '/tmp/project/.claude/skills/older',
          skillFile: '/tmp/project/.claude/skills/older/SKILL.md',
          scope: 'project',
          projectRoot: '/tmp/project',
          discoveryRoot: '/tmp/project/.claude/skills',
          name: 'Older Skill',
        }),
      ]);
      await firstFetch;

      expect(store.getState().skillsProjectCatalogByProjectPath['/tmp/project']).toEqual(
        secondResult
      );
    });

    it('keeps the previous detail cache when a detail fetch fails', async () => {
      const cachedDetail = makeSkillDetail();
      store.setState({
        skillsDetailsById: { [cachedDetail.item.id]: cachedDetail },
      });
      (api.skills!.getDetail as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('detail fail'));

      await expect(
        store.getState().fetchSkillDetail(cachedDetail.item.id, '/tmp/project')
      ).rejects.toThrow('detail fail');

      expect(store.getState().skillsDetailsById[cachedDetail.item.id]).toEqual(cachedDetail);
      expect(store.getState().skillsDetailErrorById[cachedDetail.item.id]).toBe('detail fail');
    });
  });
});
