2026-08-05 11:48:31 +03:00
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
import os from 'os';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
|
2026-08-15 05:13:32 +00:00
|
|
|
import { parseMdFile, writeMdFile, readConfigFile, readConfigLayers, writeConfig } from './shared.js';
|
2026-08-05 11:48:31 +03:00
|
|
|
import { updateAgent } from './agents.js';
|
2026-08-15 04:25:40 +00:00
|
|
|
import { updateMcpConfig } from './mcp.js';
|
2026-08-05 11:48:31 +03:00
|
|
|
|
|
|
|
|
const FIXTURE_DIR = path.join(os.tmpdir(), `openchamber-shared-test-${process.pid}`);
|
|
|
|
|
|
|
|
|
|
const STANDARD_MD = [
|
|
|
|
|
'---',
|
|
|
|
|
'description: My build agent',
|
|
|
|
|
'model: anthropic/claude-sonnet-4',
|
|
|
|
|
'mode: primary',
|
|
|
|
|
'---',
|
|
|
|
|
'',
|
|
|
|
|
'This is the prompt body.',
|
|
|
|
|
'',
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
|
|
|
const writeFixture = (name, content) => {
|
|
|
|
|
const filePath = path.join(FIXTURE_DIR, name);
|
|
|
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
|
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
|
|
|
return filePath;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('parseMdFile', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
|
|
|
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('parses standard YAML frontmatter', () => {
|
|
|
|
|
const file = writeFixture('standard.md', STANDARD_MD);
|
|
|
|
|
const { frontmatter, body } = parseMdFile(file);
|
|
|
|
|
expect(frontmatter).toEqual({
|
|
|
|
|
description: 'My build agent',
|
|
|
|
|
model: 'anthropic/claude-sonnet-4',
|
|
|
|
|
mode: 'primary',
|
|
|
|
|
});
|
|
|
|
|
expect(body).toBe('This is the prompt body.');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('parses frontmatter whose closing --- is at end-of-file without a trailing newline', () => {
|
|
|
|
|
// gray-matter (used by OpenCode) accepts this shape; OpenChamber must too,
|
|
|
|
|
// otherwise a later save duplicates the YAML block.
|
|
|
|
|
const file = writeFixture('eof-close.md', [
|
|
|
|
|
'---',
|
|
|
|
|
'description: My build agent',
|
|
|
|
|
'model: anthropic/claude-sonnet-4',
|
|
|
|
|
'---',
|
|
|
|
|
].join('\n'));
|
|
|
|
|
const { frontmatter, body } = parseMdFile(file);
|
|
|
|
|
expect(frontmatter).toEqual({
|
|
|
|
|
description: 'My build agent',
|
|
|
|
|
model: 'anthropic/claude-sonnet-4',
|
|
|
|
|
});
|
|
|
|
|
expect(body).toBe('');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('parses frontmatter with CRLF line endings', () => {
|
|
|
|
|
const file = writeFixture('crlf.md', STANDARD_MD.replace(/\n/g, '\r\n'));
|
|
|
|
|
const { frontmatter, body } = parseMdFile(file);
|
|
|
|
|
expect(frontmatter.model).toBe('anthropic/claude-sonnet-4');
|
|
|
|
|
expect(body).toBe('This is the prompt body.');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('parses frontmatter preceded by a UTF-8 BOM', () => {
|
|
|
|
|
const file = writeFixture('bom.md', `\uFEFF${STANDARD_MD}`);
|
|
|
|
|
const { frontmatter, body } = parseMdFile(file);
|
|
|
|
|
expect(frontmatter.description).toBe('My build agent');
|
|
|
|
|
expect(body).toBe('This is the prompt body.');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to lenient YAML for unquoted colons in values, matching OpenCode', () => {
|
|
|
|
|
const file = writeFixture('colon.md', [
|
|
|
|
|
'---',
|
|
|
|
|
'description: Build agent: creates builds',
|
|
|
|
|
'model: anthropic/claude-sonnet-4',
|
|
|
|
|
'---',
|
|
|
|
|
'',
|
|
|
|
|
'Body',
|
|
|
|
|
'',
|
|
|
|
|
].join('\n'));
|
|
|
|
|
const { frontmatter, body } = parseMdFile(file);
|
|
|
|
|
expect(frontmatter).toEqual({
|
|
|
|
|
description: 'Build agent: creates builds',
|
|
|
|
|
model: 'anthropic/claude-sonnet-4',
|
|
|
|
|
});
|
|
|
|
|
expect(body).toBe('Body');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('treats files without frontmatter as a plain body', () => {
|
|
|
|
|
const file = writeFixture('plain.md', 'Just a prompt body.');
|
|
|
|
|
const { frontmatter, body } = parseMdFile(file);
|
|
|
|
|
expect(frontmatter).toEqual({});
|
|
|
|
|
expect(body).toBe('Just a prompt body.');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('writeMdFile', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
|
|
|
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('round-trips a canonical single frontmatter block', () => {
|
|
|
|
|
const file = writeFixture('roundtrip.md', STANDARD_MD);
|
|
|
|
|
const parsed = parseMdFile(file);
|
|
|
|
|
parsed.frontmatter.model = 'openai/gpt-5';
|
|
|
|
|
writeMdFile(file, parsed.frontmatter, parsed.body);
|
|
|
|
|
|
|
|
|
|
const content = fs.readFileSync(file, 'utf8');
|
|
|
|
|
// Exactly one frontmatter block.
|
|
|
|
|
expect(content.match(/^---\r?\n/g)).toHaveLength(1);
|
|
|
|
|
|
|
|
|
|
const reparsed = parseMdFile(file);
|
|
|
|
|
expect(reparsed.frontmatter).toEqual({
|
|
|
|
|
description: 'My build agent',
|
|
|
|
|
model: 'openai/gpt-5',
|
|
|
|
|
mode: 'primary',
|
|
|
|
|
});
|
|
|
|
|
expect(reparsed.body).toBe('This is the prompt body.');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('updateAgent frontmatter preservation', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
|
|
|
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updates the model in place without duplicating YAML for a file with EOF-closed frontmatter', () => {
|
|
|
|
|
// Repro of OPE-178: the file's closing --- sits at EOF (no trailing
|
|
|
|
|
// newline). OpenCode parses it; OpenChamber previously treated the whole
|
|
|
|
|
// file as the prompt body and prepended a second frontmatter block on save.
|
|
|
|
|
const projectDir = path.join(FIXTURE_DIR, 'project');
|
|
|
|
|
const agentPath = path.join(projectDir, '.opencode', 'agents', 'strateg.md');
|
|
|
|
|
writeFixture(path.join('project', '.opencode', 'agents', 'strateg.md'), [
|
|
|
|
|
'---',
|
|
|
|
|
'description: Strategy agent',
|
|
|
|
|
'model: anthropic/claude-sonnet-4',
|
|
|
|
|
'temperature: 0.7',
|
|
|
|
|
'---',
|
|
|
|
|
].join('\n'));
|
|
|
|
|
|
|
|
|
|
updateAgent('strateg', { model: 'openai/gpt-5' }, projectDir);
|
|
|
|
|
|
|
|
|
|
const content = fs.readFileSync(agentPath, 'utf8');
|
|
|
|
|
expect(content.match(/^---\r?\n/g)).toHaveLength(1);
|
|
|
|
|
|
|
|
|
|
const parsed = parseMdFile(agentPath);
|
|
|
|
|
expect(parsed.frontmatter).toEqual({
|
|
|
|
|
description: 'Strategy agent',
|
|
|
|
|
model: 'openai/gpt-5',
|
|
|
|
|
temperature: 0.7,
|
|
|
|
|
});
|
|
|
|
|
expect(parsed.body).toBe('');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('preserves unrelated frontmatter fields when saving one field', () => {
|
|
|
|
|
const projectDir = path.join(FIXTURE_DIR, 'project');
|
|
|
|
|
const agentPath = path.join(projectDir, '.opencode', 'agents', 'strateg.md');
|
|
|
|
|
writeFixture(path.join('project', '.opencode', 'agents', 'strateg.md'), [
|
|
|
|
|
'---',
|
|
|
|
|
'description: Strategy agent',
|
|
|
|
|
'mode: primary',
|
|
|
|
|
'temperature: 0.7',
|
|
|
|
|
'---',
|
|
|
|
|
'',
|
|
|
|
|
'Body of strateg.',
|
|
|
|
|
'',
|
|
|
|
|
].join('\n'));
|
|
|
|
|
|
|
|
|
|
updateAgent('strateg', { description: 'Updated strategy agent' }, projectDir);
|
|
|
|
|
|
|
|
|
|
const content = fs.readFileSync(agentPath, 'utf8');
|
|
|
|
|
expect(content.match(/^---\r?\n/g)).toHaveLength(1);
|
|
|
|
|
|
|
|
|
|
const parsed = parseMdFile(agentPath);
|
|
|
|
|
expect(parsed.frontmatter).toEqual({
|
|
|
|
|
description: 'Updated strategy agent',
|
|
|
|
|
mode: 'primary',
|
|
|
|
|
temperature: 0.7,
|
|
|
|
|
});
|
|
|
|
|
expect(parsed.body).toBe('Body of strateg.');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-15 04:25:40 +00:00
|
|
|
|
|
|
|
|
describe('readConfigFile / writeConfig JSONC safety (issue #2923)', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
|
|
|
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const VALID_CONFIG = [
|
|
|
|
|
'{',
|
|
|
|
|
' "$schema": "https://opencode.ai/config.json",',
|
|
|
|
|
' // keep me',
|
|
|
|
|
' "plugin": ["opencode-see-image"],',
|
|
|
|
|
' "mcp": {',
|
|
|
|
|
' "openproject": {',
|
|
|
|
|
' "type": "remote",',
|
|
|
|
|
' "url": "https://openproject.example.com/mcp",',
|
|
|
|
|
' "enabled": true,',
|
|
|
|
|
' }',
|
|
|
|
|
' },',
|
|
|
|
|
' "provider": {',
|
|
|
|
|
' "ollama-cloud": {',
|
|
|
|
|
' "npm": "@ai-sdk/openai-compatible",',
|
|
|
|
|
' "name": "Ollama Cloud"',
|
|
|
|
|
' }',
|
|
|
|
|
' }',
|
|
|
|
|
'}',
|
|
|
|
|
'',
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
|
|
|
// JSON5-style unquoted keys after $schema — jsonc-parser returns a partial
|
|
|
|
|
// tree of only `{ $schema }` when errors are ignored.
|
|
|
|
|
const PARTIAL_PARSE_CONFIG = [
|
|
|
|
|
'{',
|
|
|
|
|
' "$schema": "https://opencode.ai/config.json",',
|
|
|
|
|
' plugin: ["opencode-see-image"],',
|
|
|
|
|
' mcp: {',
|
|
|
|
|
' openproject: {',
|
|
|
|
|
' type: "remote",',
|
|
|
|
|
' url: "https://openproject.example.com/mcp",',
|
|
|
|
|
' enabled: true',
|
|
|
|
|
' }',
|
|
|
|
|
' },',
|
|
|
|
|
' provider: {',
|
|
|
|
|
' "ollama-cloud": {',
|
|
|
|
|
' npm: "@ai-sdk/openai-compatible",',
|
|
|
|
|
' name: "Ollama Cloud"',
|
|
|
|
|
' }',
|
|
|
|
|
' }',
|
|
|
|
|
'}',
|
|
|
|
|
'',
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
|
|
|
it('parses valid JSONC with comments and trailing commas without dropping keys', () => {
|
|
|
|
|
const file = writeFixture('opencode.jsonc', VALID_CONFIG);
|
|
|
|
|
expect(readConfigFile(file)).toEqual({
|
|
|
|
|
$schema: 'https://opencode.ai/config.json',
|
|
|
|
|
plugin: ['opencode-see-image'],
|
|
|
|
|
mcp: {
|
|
|
|
|
openproject: {
|
|
|
|
|
type: 'remote',
|
|
|
|
|
url: 'https://openproject.example.com/mcp',
|
|
|
|
|
enabled: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
provider: {
|
|
|
|
|
'ollama-cloud': {
|
|
|
|
|
npm: '@ai-sdk/openai-compatible',
|
|
|
|
|
name: 'Ollama Cloud',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns an empty object for a missing or whitespace-only file', () => {
|
|
|
|
|
expect(readConfigFile(path.join(FIXTURE_DIR, 'missing.jsonc'))).toEqual({});
|
|
|
|
|
const empty = writeFixture('empty.jsonc', ' \n');
|
|
|
|
|
expect(readConfigFile(empty)).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('throws INVALID_JSONC on partial-parse JSONC instead of returning a $schema-only stub', () => {
|
|
|
|
|
const file = writeFixture('opencode.jsonc', PARTIAL_PARSE_CONFIG);
|
|
|
|
|
expect(() => readConfigFile(file)).toThrow(/cannot be loaded safely/);
|
|
|
|
|
try {
|
|
|
|
|
readConfigFile(file);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
expect(error.code).toBe('INVALID_JSONC');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('throws INVALID_JSONC for a non-object JSONC root', () => {
|
|
|
|
|
const file = writeFixture('array.jsonc', '["plugin"]\n');
|
|
|
|
|
expect(() => readConfigFile(file)).toThrow(/cannot be loaded safely/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('refuses to overwrite an unparseable config file', () => {
|
|
|
|
|
const file = writeFixture('opencode.jsonc', PARTIAL_PARSE_CONFIG);
|
|
|
|
|
expect(() => writeConfig({ $schema: 'https://opencode.ai/config.json' }, file)).toThrow(
|
|
|
|
|
/cannot be loaded safely/,
|
|
|
|
|
);
|
|
|
|
|
expect(fs.readFileSync(file, 'utf8')).toBe(PARTIAL_PARSE_CONFIG);
|
|
|
|
|
expect(fs.existsSync(`${file}.openchamber.backup`)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('preserves a valid config across MCP updates', () => {
|
|
|
|
|
const file = writeFixture('opencode.jsonc', VALID_CONFIG);
|
|
|
|
|
const config = readConfigFile(file);
|
|
|
|
|
config.mcp.openproject.enabled = false;
|
|
|
|
|
writeConfig(config, file);
|
|
|
|
|
|
|
|
|
|
const rewritten = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
|
|
|
expect(rewritten.plugin).toEqual(['opencode-see-image']);
|
|
|
|
|
expect(rewritten.provider['ollama-cloud'].name).toBe('Ollama Cloud');
|
|
|
|
|
expect(rewritten.mcp.openproject.enabled).toBe(false);
|
|
|
|
|
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(VALID_CONFIG);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not wipe an unparseable user config during MCP mutation attempts', () => {
|
|
|
|
|
const file = writeFixture('opencode.jsonc', PARTIAL_PARSE_CONFIG);
|
|
|
|
|
const previousOpenCodeConfig = process.env.OPENCODE_CONFIG;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
process.env.OPENCODE_CONFIG = file;
|
|
|
|
|
expect(() => updateMcpConfig('openproject', { enabled: true })).toThrow(
|
|
|
|
|
/cannot be loaded safely/,
|
|
|
|
|
);
|
|
|
|
|
expect(fs.readFileSync(file, 'utf8')).toBe(PARTIAL_PARSE_CONFIG);
|
|
|
|
|
expect(fs.existsSync(`${file}.openchamber.backup`)).toBe(false);
|
|
|
|
|
} finally {
|
|
|
|
|
if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
|
|
|
|
|
else process.env.OPENCODE_CONFIG = previousOpenCodeConfig;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-15 05:13:32 +00:00
|
|
|
|
|
|
|
|
it('returns an empty object for a comment-only config file', () => {
|
|
|
|
|
const file = writeFixture('comments.jsonc', '// placeholder\n/* still empty */\n');
|
|
|
|
|
expect(readConfigFile(file)).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 16:29:12 +03:00
|
|
|
it('throws INVALID_JSONC for content that yields no JSON value at all', () => {
|
|
|
|
|
const yamlish = writeFixture('yamlish.jsonc', 'mcp:\n openproject:\n type: remote\n');
|
|
|
|
|
expect(() => readConfigFile(yamlish)).toThrow(/cannot be loaded safely/);
|
|
|
|
|
expect(() => writeConfig({ $schema: 'https://opencode.ai/config.json' }, yamlish)).toThrow(
|
|
|
|
|
/cannot be loaded safely/,
|
|
|
|
|
);
|
|
|
|
|
expect(fs.readFileSync(yamlish, 'utf8')).toBe('mcp:\n openproject:\n type: remote\n');
|
|
|
|
|
expect(fs.existsSync(`${yamlish}.openchamber.backup`)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-15 05:13:32 +00:00
|
|
|
it('keeps a valid custom layer readable when a project layer is unparseable', () => {
|
|
|
|
|
const custom = writeFixture('custom.jsonc', VALID_CONFIG);
|
|
|
|
|
const projectDir = path.join(FIXTURE_DIR, 'project');
|
|
|
|
|
const projectFile = writeFixture(path.join('project', '.opencode', 'opencode.jsonc'), PARTIAL_PARSE_CONFIG);
|
|
|
|
|
const previousOpenCodeConfig = process.env.OPENCODE_CONFIG;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
process.env.OPENCODE_CONFIG = custom;
|
|
|
|
|
const layers = readConfigLayers(projectDir);
|
|
|
|
|
expect(layers.customConfig.plugin).toEqual(['opencode-see-image']);
|
|
|
|
|
expect(layers.projectConfig).toEqual({});
|
|
|
|
|
expect(layers.mergedConfig.plugin).toEqual(['opencode-see-image']);
|
|
|
|
|
expect(layers.layerErrors).toEqual([
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
path: projectFile,
|
|
|
|
|
code: 'INVALID_JSONC',
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
updateMcpConfig('openproject', { enabled: false }, projectDir);
|
|
|
|
|
const rewritten = JSON.parse(fs.readFileSync(custom, 'utf8'));
|
|
|
|
|
expect(rewritten.plugin).toEqual(['opencode-see-image']);
|
|
|
|
|
expect(rewritten.mcp.openproject.enabled).toBe(false);
|
|
|
|
|
expect(fs.readFileSync(projectFile, 'utf8')).toBe(PARTIAL_PARSE_CONFIG);
|
|
|
|
|
expect(fs.existsSync(`${projectFile}.openchamber.backup`)).toBe(false);
|
|
|
|
|
} finally {
|
|
|
|
|
if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
|
|
|
|
|
else process.env.OPENCODE_CONFIG = previousOpenCodeConfig;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-15 04:25:40 +00:00
|
|
|
});
|