From ad2d90752404649ab2ef8fe67353da335a6ffb97 Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 17 Jun 2026 02:19:35 -0400 Subject: [PATCH 1/2] feat(goose): implement agent configuration for workspace preparation Replaces the configurationFiles and preWorkspaceStart stubs with a real implementation that writes GOOSE_MODEL into .goose/config.yaml from the workspace context, preserving existing configuration fields. Fixes: #2174 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Brian --- extensions/goose/src/extension.spec.ts | 83 +++++++++++++++++++++++++- extensions/goose/src/extension.ts | 26 ++++++-- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/extensions/goose/src/extension.spec.ts b/extensions/goose/src/extension.spec.ts index 0a9f3f7906..60a7157148 100644 --- a/extensions/goose/src/extension.spec.ts +++ b/extensions/goose/src/extension.spec.ts @@ -16,11 +16,11 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { Disposable, ExtensionContext } from '@openkaiden/api'; +import type { 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, GOOSE_CONFIG_PATH } from './extension'; const AGENT_DISPOSABLE_MOCK: Disposable = { dispose: vi.fn() }; @@ -74,4 +74,83 @@ describe('activate', () => { expect(agent.isSupportedModelType!({ name: 'gemini' })).toBe(true); expect(agent.isSupportedModelType!({ name: 'openai' })).toBe(true); }); + + test('registers agent with config.yaml configuration file', async () => { + await activate(extensionContextMock); + + const agent = vi.mocked(agents.registerAgent).mock.calls[0]![0]; + expect(agent.configurationFiles).toHaveLength(1); + expect(agent.configurationFiles[0]!.path).toBe(GOOSE_CONFIG_PATH); + }); + + describe('preWorkspaceStart', () => { + function createContext(configFiles: AgentConfigurationFile[], modelLabel = 'gpt-4o'): AgentWorkspaceContext { + return { + model: { + model: { label: modelLabel }, + }, + configurationFiles: configFiles, + }; + } + + function createConfigFile(content = ''): AgentConfigurationFile & { updateMock: ReturnType } { + const updateMock = vi.fn(); + const file: AgentConfigurationFile = { + path: GOOSE_CONFIG_PATH, + read: vi.fn().mockResolvedValue(content), + update: updateMock, + }; + return Object.assign(file, { updateMock }); + } + + test('writes model configuration into config.yaml', async () => { + await activate(extensionContextMock); + const agent = vi.mocked(agents.registerAgent).mock.calls[0]![0]; + + const configFile = createConfigFile(); + await agent.preWorkspaceStart(createContext([configFile])); + + expect(configFile.updateMock).toHaveBeenCalledOnce(); + expect(configFile.updateMock.mock.calls[0]![0]).toBe('GOOSE_MODEL: gpt-4o\n'); + }); + + test('preserves existing configuration fields', async () => { + await activate(extensionContextMock); + const agent = vi.mocked(agents.registerAgent).mock.calls[0]![0]; + + const configFile = createConfigFile('GOOSE_PROVIDER: openai\nGOOSE_MODEL: old-model\n'); + await agent.preWorkspaceStart(createContext([configFile], 'claude-sonnet')); + + const written = configFile.updateMock.mock.calls[0]![0] as string; + expect(written).toContain('GOOSE_PROVIDER: openai'); + expect(written).toContain('GOOSE_MODEL: claude-sonnet'); + expect(written).not.toContain('old-model'); + }); + + test('handles empty config file', async () => { + await activate(extensionContextMock); + const agent = vi.mocked(agents.registerAgent).mock.calls[0]![0]; + + const configFile = createConfigFile(''); + await agent.preWorkspaceStart(createContext([configFile], 'gemini-2.5-pro')); + + expect(configFile.updateMock.mock.calls[0]![0]).toBe('GOOSE_MODEL: gemini-2.5-pro\n'); + }); + + test('does nothing when config file is not in context', async () => { + await activate(extensionContextMock); + const agent = vi.mocked(agents.registerAgent).mock.calls[0]![0]; + + const updateMock = vi.fn(); + const otherFile: AgentConfigurationFile = { + path: 'some/other/path.yaml', + read: vi.fn(), + update: updateMock, + }; + + await agent.preWorkspaceStart(createContext([otherFile])); + + expect(updateMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/extensions/goose/src/extension.ts b/extensions/goose/src/extension.ts index c23ac9f90b..47043e2abb 100644 --- a/extensions/goose/src/extension.ts +++ b/extensions/goose/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 GOOSE_CONFIG_PATH = '.goose/config.yaml'; + export async function activate(extensionContext: ExtensionContext): Promise { const disposable = agents.registerAgent({ id: 'goose', @@ -30,7 +32,14 @@ export async function activate(extensionContext: ExtensionContext): Promise { + return ''; + }, + }, + ], destinationSkillsFolder: '${HOME}/.agents/skills', isSupportedRuntime(runtime): boolean { return runtime === 'podman'; @@ -38,8 +47,17 @@ export async function activate(extensionContext: ExtensionContext): Promise { - throw new Error('not implemented'); + async preWorkspaceStart(context: AgentWorkspaceContext): Promise { + const configFile = context.configurationFiles.find(f => f.path === GOOSE_CONFIG_PATH); + if (!configFile) { + return; + } + + const content = await configFile.read(); + const lines = content.split('\n').filter(line => !line.startsWith('GOOSE_MODEL:')); + lines.push(`GOOSE_MODEL: ${context.model.model.label}`); + + await configFile.update(lines.filter(line => line.trim() !== '').join('\n') + '\n'); }, }); extensionContext.subscriptions.push(disposable); From af81505edcac35dcf3126713745169c360ba6518 Mon Sep 17 00:00:00 2001 From: Brian Date: Thu, 18 Jun 2026 09:26:02 -0400 Subject: [PATCH 2/2] feat(goose): use proper YAML parsing to preserve comments and formatting Replace naive line-splitting with the `yaml` library's AST-based parsing so that user comments and formatting in .goose/config.yaml survive model config updates. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Brian --- extensions/goose/package.json | 3 ++- extensions/goose/src/extension.spec.ts | 13 +++++++++++++ extensions/goose/src/extension.ts | 25 +++++++++++++++++++++---- pnpm-lock.yaml | 3 +++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/extensions/goose/package.json b/extensions/goose/package.json index 388db5c507..279cac46ab 100644 --- a/extensions/goose/package.json +++ b/extensions/goose/package.json @@ -23,7 +23,8 @@ "watch": "vite build --watch" }, "dependencies": { - "@openkaiden/api": "workspace:*" + "@openkaiden/api": "workspace:*", + "yaml": "^2.8.2" }, "devDependencies": { "adm-zip": "^0.5.16", diff --git a/extensions/goose/src/extension.spec.ts b/extensions/goose/src/extension.spec.ts index 60a7157148..26869b47cd 100644 --- a/extensions/goose/src/extension.spec.ts +++ b/extensions/goose/src/extension.spec.ts @@ -127,6 +127,19 @@ describe('activate', () => { expect(written).not.toContain('old-model'); }); + test('preserves existing YAML comments', async () => { + await activate(extensionContextMock); + const agent = vi.mocked(agents.registerAgent).mock.calls[0]![0]; + + const configFile = createConfigFile('# existing comment\nGOOSE_PROVIDER: openai\n'); + await agent.preWorkspaceStart(createContext([configFile], 'claude-sonnet')); + + const written = configFile.updateMock.mock.calls[0]![0] as string; + expect(written).toContain('# existing comment'); + expect(written).toContain('GOOSE_PROVIDER: openai'); + expect(written).toContain('GOOSE_MODEL: claude-sonnet'); + }); + test('handles empty config file', async () => { await activate(extensionContextMock); const agent = vi.mocked(agents.registerAgent).mock.calls[0]![0]; diff --git a/extensions/goose/src/extension.ts b/extensions/goose/src/extension.ts index 47043e2abb..b36780032e 100644 --- a/extensions/goose/src/extension.ts +++ b/extensions/goose/src/extension.ts @@ -18,9 +18,29 @@ import type { AgentWorkspaceContext, ExtensionContext } from '@openkaiden/api'; import { agents } from '@openkaiden/api'; +import { isMap, parseDocument } from 'yaml'; export const GOOSE_CONFIG_PATH = '.goose/config.yaml'; +function updateGooseModelConfig(content: string, modelLabel: string): string { + const document = parseDocument(content); + + if (document.errors.length > 0) { + throw document.errors[0]; + } + + if (document.contents === null) { + document.contents = document.createNode({}); + } else if (!isMap(document.contents)) { + throw new Error('Goose config must be a YAML mapping.'); + } + + document.set('GOOSE_MODEL', modelLabel); + + const updatedContent = document.toString(); + return updatedContent.endsWith('\n') ? updatedContent : `${updatedContent}\n`; +} + export async function activate(extensionContext: ExtensionContext): Promise { const disposable = agents.registerAgent({ id: 'goose', @@ -54,10 +74,7 @@ export async function activate(extensionContext: ExtensionContext): Promise !line.startsWith('GOOSE_MODEL:')); - lines.push(`GOOSE_MODEL: ${context.model.model.label}`); - - await configFile.update(lines.filter(line => line.trim() !== '').join('\n') + '\n'); + await configFile.update(updateGooseModelConfig(content, context.model.model.label)); }, }); extensionContext.subscriptions.push(disposable); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2043e95bfd..51cc3b8b37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,6 +580,9 @@ importers: '@openkaiden/api': specifier: workspace:* version: link:../../packages/extension-api + yaml: + specifier: ^2.8.2 + version: 2.8.2 devDependencies: adm-zip: specifier: ^0.5.16