diff --git a/extensions/copilot/src/extension.spec.ts b/extensions/copilot/src/extension.spec.ts index 1e6aed5e89..1445209781 100644 --- a/extensions/copilot/src/extension.spec.ts +++ b/extensions/copilot/src/extension.spec.ts @@ -16,11 +16,17 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { Disposable, ExtensionContext } from '@openkaiden/api'; +import type { + Agent, + AgentConfigurationFile, + AgentWorkspaceContext, + Disposable, + ExtensionContext, +} from '@openkaiden/api'; import { agents } from '@openkaiden/api'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { activate } from './extension'; +import { activate, COPILOT_SETTINGS_PATH } from './extension'; const AGENT_DISPOSABLE_MOCK: Disposable = { dispose: vi.fn() }; @@ -36,6 +42,30 @@ beforeEach(() => { vi.mocked(agents.registerAgent).mockReturnValue(AGENT_DISPOSABLE_MOCK); }); +function getRegisteredAgent(): Agent { + return vi.mocked(agents.registerAgent).mock.calls[0]![0]; +} + +function createContext(configFiles: AgentConfigurationFile[], modelLabel = 'gpt-4o'): AgentWorkspaceContext { + return { + model: { + model: { label: modelLabel }, + }, + configurationFiles: configFiles, + workspace: {}, + }; +} + +function createConfigFile(content = '{}'): AgentConfigurationFile & { updateMock: ReturnType } { + const updateMock = vi.fn(); + const file: AgentConfigurationFile = { + path: COPILOT_SETTINGS_PATH, + read: vi.fn().mockResolvedValue(content), + update: updateMock, + }; + return Object.assign(file, { updateMock }); +} + describe('activate', () => { test('registers copilot agent', async () => { await activate(extensionContextMock); @@ -61,9 +91,93 @@ describe('activate', () => { test('registered agent supports all model types except vertexai', async () => { await activate(extensionContextMock); - const agent = vi.mocked(agents.registerAgent).mock.calls[0]![0]; + const agent = getRegisteredAgent(); expect(agent.isSupportedModelType!({ name: 'openai' })).toBe(true); expect(agent.isSupportedModelType!({ name: 'gemini' })).toBe(true); expect(agent.isSupportedModelType!({ name: 'vertexai' })).toBe(false); }); + + test('registers agent with settings.json configuration file', async () => { + await activate(extensionContextMock); + + const agent = getRegisteredAgent(); + expect(agent.configurationFiles).toHaveLength(1); + expect(agent.configurationFiles[0]!.path).toBe(COPILOT_SETTINGS_PATH); + }); + + describe('preWorkspaceStart', () => { + test('writes model configuration into settings.json', async () => { + await activate(extensionContextMock); + const agent = getRegisteredAgent(); + + const configFile = createConfigFile(); + await agent.preWorkspaceStart(createContext([configFile])); + + expect(configFile.updateMock).toHaveBeenCalledOnce(); + const written = JSON.parse(configFile.updateMock.mock.calls[0]![0] as string); + expect(written).toEqual({ + chat: { model: 'gpt-4o' }, + }); + }); + + test('preserves existing configuration fields', async () => { + await activate(extensionContextMock); + const agent = getRegisteredAgent(); + + const existingConfig = JSON.stringify({ chat: { model: 'old-model', temperature: 0.7 }, other: true }); + const configFile = createConfigFile(existingConfig); + await agent.preWorkspaceStart(createContext([configFile], 'claude-sonnet')); + + const written = JSON.parse(configFile.updateMock.mock.calls[0]![0] as string); + expect(written.chat.model).toBe('claude-sonnet'); + expect(written.chat.temperature).toBe(0.7); + expect(written.other).toBe(true); + }); + + test.each([ + 'null', + '"a string"', + '123', + 'true', + '[1, 2]', + ])('falls back to empty config when parsed JSON is non-object: %s', async (payload: string) => { + await activate(extensionContextMock); + const agent = getRegisteredAgent(); + + const configFile = createConfigFile(payload); + await agent.preWorkspaceStart(createContext([configFile])); + + expect(configFile.updateMock).toHaveBeenCalledOnce(); + const written = JSON.parse(configFile.updateMock.mock.calls[0]![0] as string); + expect(written.chat.model).toBe('gpt-4o'); + }); + + test('handles invalid JSON by starting with empty config', async () => { + await activate(extensionContextMock); + const agent = getRegisteredAgent(); + + const configFile = createConfigFile('not valid json'); + await agent.preWorkspaceStart(createContext([configFile])); + + expect(configFile.updateMock).toHaveBeenCalledOnce(); + const written = JSON.parse(configFile.updateMock.mock.calls[0]![0] as string); + expect(written.chat.model).toBe('gpt-4o'); + }); + + test('does nothing when config file is not in context', async () => { + await activate(extensionContextMock); + const agent = getRegisteredAgent(); + + const updateMock = vi.fn(); + const otherFile: AgentConfigurationFile = { + path: 'some/other/path.json', + read: vi.fn(), + update: updateMock, + }; + + await agent.preWorkspaceStart(createContext([otherFile])); + + expect(updateMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/extensions/copilot/src/extension.ts b/extensions/copilot/src/extension.ts index 0997ea360f..1d57528f72 100644 --- a/extensions/copilot/src/extension.ts +++ b/extensions/copilot/src/extension.ts @@ -16,9 +16,11 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { ExtensionContext } from '@openkaiden/api'; +import type { AgentWorkspaceContext, ExtensionContext } from '@openkaiden/api'; import { agents } from '@openkaiden/api'; +export const COPILOT_SETTINGS_PATH = '.copilot/settings.json'; + export async function activate(extensionContext: ExtensionContext): Promise { const disposable = agents.registerAgent({ id: 'copilot', @@ -39,13 +41,41 @@ export async function activate(extensionContext: ExtensionContext): Promise { + return '{}'; + }, + }, + ], isSupportedModelType(type): boolean { return type.name !== 'vertexai'; }, - async preWorkspaceStart(): Promise { - throw new Error('not implemented'); + async preWorkspaceStart(context: AgentWorkspaceContext): Promise { + const configFile = context.configurationFiles.find(f => f.path === COPILOT_SETTINGS_PATH); + if (!configFile) { + return; + } + + const content = await configFile.read(); + let config: Record; + try { + const parsed: unknown = JSON.parse(content); + config = + typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + config = {}; + } + + const chat = (config.chat as Record | undefined) ?? {}; + chat.model = context.model.model.label; + config.chat = chat; + + await configFile.update(JSON.stringify(config, undefined, 2)); }, }); extensionContext.subscriptions.push(disposable);