Files
Kilo-Org_kilocode/packages/plugin-atomic-chat/test/plugin.test.ts
yanalialiuk 2d109462db Add Atomic Chat as a first-class local provider. (#10555)
* Add Atomic Chat as a first-class local provider.

Ship the atomic-chat plugin with auto-discovery on localhost:1337, register the provider in models.dev fixtures, and document setup for VS Code and CLI.

* Remove dead code

Removed atomicChat provider mapping from legacy migration.

* Update Atomic Chat provider icon to theme-colored vector.

Replace the embedded raster icon with a currentColor SVG symbol so it matches provider icon styling and inherits UI color consistently.

* Allow optional API key for local Atomic Chat and LM Studio providers.

Load the atomic-chat plugin despite named constant exports, register local-server auth in the plugin, and let CLI and VS Code connect with an empty key on localhost.

* Gate Atomic Chat discovery behind explicit opt-in

* Update packages/plugin-atomic-chat/src/utils/should-probe-atomic-chat.ts

* Remove dead branch in atomic-chat model ref probe.

Drop the modelID check (never equals provider key) and the duplicate providerID branch.

* Fix PR CI: register atomic chat plugin and complete i18n.

Restore atomic-chat in default-plugins after main merge, add missing sidebar locale keys, and annotate shared upstream diffs.

* Format i18n locale files and ProviderConnectDialog with Prettier.

* Address PR review: docs order, narrow annotations, plugin fixes.

Reorder local-models providers per maintainer feedback; use inline kilocode_change markers in dialog-provider. Share ModelStatusCache, fix config discovery abort/timeout, throw on fetch failure, and resolve bot review warnings.

* Align dialog-provider with upstream keymap bindings after rebase.

Restore useBindings instead of useKeyboard so shared-file diff stays minimal and passes annotation checks.

* Update packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx

* Deduplicate Atomic Chat /v1/models fetch on config discovery.

Use a single shared models endpoint request in enhanceConfig instead of separate health and discovery calls.

* Fix kilo-code-bot review: import provider key and log cache errors.

Export ATOMIC_CHAT_PROVIDER_KEY from local-providers for the webview, log cache warm failures, and throw on network errors in fetchModelsEndpoint.

* fix(plugin-atomic-chat): harden discovery and reduce chat toasts

Reuse auto-detect model list to avoid duplicate /v1/models calls, wire AbortSignal into fetch, replace silent catches with logging, and only surface validation errors in chat.params.

* fix(plugin-atomic-chat): refresh model cache on chat validation retries

Bypass the 15s model list cache after the first failed attempt so retryWithBackoff can observe newly loaded models.

* Update packages/plugin-atomic-chat/src/utils/index.ts

* fix(plugin-atomic-chat): repair categorizeError not_found branch

Restore return statement and map "not loaded" validation errors to not_found after bot commit broke the if block.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com>
2026-06-04 16:03:00 +02:00

252 lines
7.4 KiB
TypeScript

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' })
)
})
})
})