2d109462db
* 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>
252 lines
7.4 KiB
TypeScript
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' })
|
|
)
|
|
})
|
|
})
|
|
})
|