import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { AtomicChatPlugin } from '../src/index' import { ATOMIC_CHAT_PROVIDER_KEY } from '../src/constants' import { sharedModelStatusCache } from '../src/cache/shared-model-status-cache' const mockFetch = vi.fn() global.fetch = mockFetch if (!global.AbortSignal.timeout) { global.AbortSignal.timeout = vi.fn(() => { const controller = new AbortController() setTimeout(() => controller.abort(), 3000) return controller.signal }) } describe('AtomicChatPlugin', () => { let mockClient: any let pluginHooks: any beforeEach(async () => { mockFetch.mockClear() sharedModelStatusCache.invalidateAll() mockClient = { tui: { showToast: vi.fn().mockResolvedValue(true), }, } const mockInput: any = { client: mockClient, project: { id: 'test-project', name: 'test', path: '/tmp', worktree: '', time: { created: Date.now() }, }, directory: '/tmp', worktree: '', $: vi.fn(), } pluginHooks = await AtomicChatPlugin(mockInput) }) afterEach(() => { vi.restoreAllMocks() }) it('initializes hooks', async () => { const mockInput: any = { client: mockClient, project: { id: 'test-project', name: 'test', path: '/tmp', worktree: '', time: { created: Date.now() }, }, directory: '/tmp', worktree: '', $: vi.fn(), } const hooks = await AtomicChatPlugin(mockInput) expect(hooks.config).toBeTypeOf('function') expect(hooks.event).toBeTypeOf('function') expect(hooks['chat.params']).toBeTypeOf('function') }) it('registers optional local-server auth (no API key required)', async () => { expect(pluginHooks.auth?.provider).toBe(ATOMIC_CHAT_PROVIDER_KEY) expect(pluginHooks.auth?.methods[0]?.type).toBe('api') expect(pluginHooks.auth?.methods[0]?.label).toBe('Local server') }) it('handles invalid client', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const hooks = await AtomicChatPlugin({ client: null } as any) expect(hooks.config).toBeTypeOf('function') expect(consoleSpy).toHaveBeenCalledWith('[@kilocode/plugin-atomic-chat] Invalid client provided to plugin') consoleSpy.mockRestore() }) describe('config hook', () => { it('rejects invalid config', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) await pluginHooks.config(null) expect(consoleSpy).toHaveBeenCalled() consoleSpy.mockRestore() }) it('does not probe localhost when Atomic Chat is not configured', async () => { const config: any = {} await pluginHooks.config(config) expect(mockFetch).not.toHaveBeenCalled() expect(config.provider?.[ATOMIC_CHAT_PROVIDER_KEY]).toBeUndefined() }) it('auto-detects only when atomicChat.autoDetect is enabled', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'm1', object: 'model', created: 1, owned_by: 'local' }], }), }) const config: any = { atomicChat: { autoDetect: true } } await pluginHooks.config(config) expect(mockFetch).toHaveBeenCalled() expect(config.provider?.[ATOMIC_CHAT_PROVIDER_KEY]).toBeDefined() expect(config.provider[ATOMIC_CHAT_PROVIDER_KEY].options.baseURL).toBe('http://127.0.0.1:1337/v1') }) it('merges discovered models', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'new-model', object: 'model', created: 1, owned_by: 'local' }], }), }) const config: any = { provider: { [ATOMIC_CHAT_PROVIDER_KEY]: { npm: '@ai-sdk/openai-compatible', name: 'Atomic Chat (local)', options: { baseURL: 'http://127.0.0.1:1337/v1' }, models: { 'existing-model': { name: 'Existing Model' }, }, }, }, } await pluginHooks.config(config) expect(config.provider[ATOMIC_CHAT_PROVIDER_KEY].models).toEqual({ 'existing-model': { name: 'Existing Model' }, 'new-model': expect.objectContaining({ id: 'new-model', name: 'New Model', }), }) }) it('handles offline API', async () => { mockFetch.mockRejectedValue(new Error('Connection refused')) const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const config: any = { provider: { [ATOMIC_CHAT_PROVIDER_KEY]: { npm: '@ai-sdk/openai-compatible', name: 'Atomic Chat (local)', options: { baseURL: 'http://127.0.0.1:1337/v1' }, }, }, } await pluginHooks.config(config) expect(consoleSpy).toHaveBeenCalled() consoleSpy.mockRestore() }) }) describe('event hook', () => { it('validates event', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) await pluginHooks.event({ event: null }) expect(consoleSpy).toHaveBeenCalled() consoleSpy.mockRestore() }) it('accepts session events', async () => { await pluginHooks.event({ event: { type: 'session.created' } }) expect(true).toBe(true) }) }) describe('chat.params hook', () => { it('rejects invalid input', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) await pluginHooks['chat.params'](null, {}) expect(consoleSpy).toHaveBeenCalled() consoleSpy.mockRestore() }) it('skips other providers', async () => { const output: any = {} await pluginHooks['chat.params']( { model: { id: 'x' }, provider: { info: { id: 'anthropic' } }, }, output ) expect(output).toEqual({}) expect(mockClient.tui.showToast).not.toHaveBeenCalled() }) it('validates model availability', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'test-model', object: 'model', created: 1, owned_by: 'local' }], }), }) const output: any = {} await pluginHooks['chat.params']( { sessionID: 's1', model: { id: 'test-model' }, provider: { info: { id: ATOMIC_CHAT_PROVIDER_KEY }, options: { baseURL: 'http://127.0.0.1:1337/v1' }, }, }, output ) expect(mockClient.tui.showToast).not.toHaveBeenCalled() expect(output.options?.atomicChatValidation).toEqual( expect.objectContaining({ status: 'success', model: 'test-model' }) ) }) it('handles missing model', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ data: [] }), }) const output: any = {} await pluginHooks['chat.params']( { sessionID: 's1', model: { id: 'missing' }, provider: { info: { id: ATOMIC_CHAT_PROVIDER_KEY }, options: { baseURL: 'http://127.0.0.1:1337/v1' }, }, }, output ) expect(output.options?.atomicChatValidation).toEqual( expect.objectContaining({ status: 'error', model: 'missing' }) ) }) }) })