From 00be7a208ca30a1d456c9acc5bd4e5ce9ef5e35f Mon Sep 17 00:00:00 2001 From: Evzen Gasta Date: Tue, 16 Jun 2026 13:45:14 +0200 Subject: [PATCH] feat(copilot): add agent configuration and model settings support Implement settings.json configuration file management for the copilot agent. The preWorkspaceStart hook now writes the selected model into the copilot settings file so the agent launches with the correct model. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Evzen Gasta --- extensions/copilot/src/extension.spec.ts | 120 ++++++++++++++++++++++- extensions/copilot/src/extension.ts | 38 ++++++- 2 files changed, 151 insertions(+), 7 deletions(-) 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);