opencode-dux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/agents/descriptions.d.ts +6 -0
- package/dist/agents/designer.d.ts +2 -0
- package/dist/agents/explorer.d.ts +2 -0
- package/dist/agents/fixer.d.ts +2 -0
- package/dist/agents/index.d.ts +22 -0
- package/dist/agents/interpreter.d.ts +2 -0
- package/dist/agents/librarian.d.ts +2 -0
- package/dist/agents/oracle.d.ts +2 -0
- package/dist/agents/orchestrator.d.ts +27 -0
- package/dist/agents/overrides.d.ts +18 -0
- package/dist/agents/prompt-blocks.d.ts +97 -0
- package/dist/agents/steward.d.ts +3 -0
- package/dist/cli/config-io.d.ts +24 -0
- package/dist/cli/config-manager.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1006 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/mcps.d.ts +13 -0
- package/dist/cli/model-key-normalization.d.ts +1 -0
- package/dist/cli/paths.d.ts +35 -0
- package/dist/cli/providers.d.ts +137 -0
- package/dist/cli/skills.d.ts +22 -0
- package/dist/cli/system.d.ts +5 -0
- package/dist/cli/types.d.ts +38 -0
- package/dist/config/constants.d.ts +12 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/loader.d.ts +40 -0
- package/dist/config/runtime-preset.d.ts +12 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/utils.d.ts +10 -0
- package/dist/discovery/local/types.d.ts +79 -0
- package/dist/discovery/local.d.ts +73 -0
- package/dist/discovery/mcp-servers.d.ts +88 -0
- package/dist/discovery/skills.d.ts +94 -0
- package/dist/hooks/apply-patch/codec.d.ts +7 -0
- package/dist/hooks/apply-patch/errors.d.ts +25 -0
- package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
- package/dist/hooks/apply-patch/index.d.ts +15 -0
- package/dist/hooks/apply-patch/matching.d.ts +26 -0
- package/dist/hooks/apply-patch/operations.d.ts +3 -0
- package/dist/hooks/apply-patch/patch.d.ts +2 -0
- package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
- package/dist/hooks/apply-patch/resolution.d.ts +19 -0
- package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
- package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
- package/dist/hooks/apply-patch/types.d.ts +80 -0
- package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
- package/dist/hooks/auto-update-checker/index.d.ts +18 -0
- package/dist/hooks/auto-update-checker/types.d.ts +22 -0
- package/dist/hooks/chat-headers.d.ts +16 -0
- package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
- package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
- package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
- package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
- package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
- package/dist/hooks/filter-available-skills/index.d.ts +32 -0
- package/dist/hooks/foreground-fallback/index.d.ts +72 -0
- package/dist/hooks/image-hook.d.ts +5 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
- package/dist/hooks/json-error-recovery/index.d.ts +1 -0
- package/dist/hooks/phase-reminder/index.d.ts +26 -0
- package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
- package/dist/hooks/task-session-manager/index.d.ts +52 -0
- package/dist/hooks/todo-continuation/index.d.ts +53 -0
- package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +31782 -0
- package/dist/mcp/context7.d.ts +6 -0
- package/dist/mcp/grep-app.d.ts +6 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/websearch.d.ts +9 -0
- package/dist/skills/registry.d.ts +29 -0
- package/dist/subscriptions/accounts-store.d.ts +57 -0
- package/dist/subscriptions/index.d.ts +13 -0
- package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
- package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
- package/dist/subscriptions/types.d.ts +115 -0
- package/dist/subscriptions/usage-service.d.ts +74 -0
- package/dist/tools/ast-grep/cli.d.ts +15 -0
- package/dist/tools/ast-grep/constants.d.ts +25 -0
- package/dist/tools/ast-grep/downloader.d.ts +5 -0
- package/dist/tools/ast-grep/index.d.ts +10 -0
- package/dist/tools/ast-grep/tools.d.ts +3 -0
- package/dist/tools/ast-grep/types.d.ts +30 -0
- package/dist/tools/ast-grep/utils.d.ts +4 -0
- package/dist/tools/delegate.d.ts +14 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/preset-manager.d.ts +27 -0
- package/dist/tools/smartfetch/binary.d.ts +3 -0
- package/dist/tools/smartfetch/cache.d.ts +6 -0
- package/dist/tools/smartfetch/constants.d.ts +12 -0
- package/dist/tools/smartfetch/index.d.ts +3 -0
- package/dist/tools/smartfetch/network.d.ts +38 -0
- package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
- package/dist/tools/smartfetch/tool.d.ts +3 -0
- package/dist/tools/smartfetch/types.d.ts +122 -0
- package/dist/tools/smartfetch/utils.d.ts +18 -0
- package/dist/tui-state.d.ts +168 -0
- package/dist/tui.d.ts +37 -0
- package/dist/tui.js +1896 -0
- package/dist/utils/agent-variant.d.ts +63 -0
- package/dist/utils/compat.d.ts +30 -0
- package/dist/utils/env.d.ts +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/internal-initiator.d.ts +6 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/polling.d.ts +21 -0
- package/dist/utils/session-manager.d.ts +55 -0
- package/dist/utils/session.d.ts +90 -0
- package/dist/utils/subagent-depth.d.ts +35 -0
- package/dist/utils/system-collapse.d.ts +6 -0
- package/dist/utils/task.d.ts +4 -0
- package/dist/utils/zip-extractor.d.ts +1 -0
- package/index.ts +1 -0
- package/opencode-dux.schema.json +634 -0
- package/package.json +103 -0
- package/src/agents/descriptions.ts +55 -0
- package/src/agents/designer.test.ts +86 -0
- package/src/agents/designer.ts +154 -0
- package/src/agents/display-name.test.ts +186 -0
- package/src/agents/explorer.test.ts +79 -0
- package/src/agents/explorer.ts +144 -0
- package/src/agents/fixer.test.ts +79 -0
- package/src/agents/fixer.ts +145 -0
- package/src/agents/index.test.ts +472 -0
- package/src/agents/index.ts +248 -0
- package/src/agents/interpreter.ts +136 -0
- package/src/agents/librarian.test.ts +80 -0
- package/src/agents/librarian.ts +145 -0
- package/src/agents/oracle.test.ts +89 -0
- package/src/agents/oracle.ts +184 -0
- package/src/agents/orchestrator.test.ts +116 -0
- package/src/agents/orchestrator.ts +574 -0
- package/src/agents/overrides.ts +95 -0
- package/src/agents/prompt-blocks.test.ts +114 -0
- package/src/agents/prompt-blocks.ts +640 -0
- package/src/agents/steward.ts +146 -0
- package/src/cli/config-io.test.ts +536 -0
- package/src/cli/config-io.ts +473 -0
- package/src/cli/config-manager.test.ts +141 -0
- package/src/cli/config-manager.ts +4 -0
- package/src/cli/index.ts +88 -0
- package/src/cli/install.ts +282 -0
- package/src/cli/mcps.test.ts +62 -0
- package/src/cli/mcps.ts +39 -0
- package/src/cli/model-key-normalization.test.ts +21 -0
- package/src/cli/model-key-normalization.ts +60 -0
- package/src/cli/paths.test.ts +167 -0
- package/src/cli/paths.ts +144 -0
- package/src/cli/providers.test.ts +118 -0
- package/src/cli/providers.ts +141 -0
- package/src/cli/skills.test.ts +111 -0
- package/src/cli/skills.ts +103 -0
- package/src/cli/system.test.ts +91 -0
- package/src/cli/system.ts +180 -0
- package/src/cli/types.ts +43 -0
- package/src/config/constants.ts +58 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.test.ts +1194 -0
- package/src/config/loader.ts +269 -0
- package/src/config/model-resolution.test.ts +176 -0
- package/src/config/runtime-preset.test.ts +61 -0
- package/src/config/runtime-preset.ts +37 -0
- package/src/config/schema.ts +248 -0
- package/src/config/utils.test.ts +41 -0
- package/src/config/utils.ts +23 -0
- package/src/discovery/local/types.ts +85 -0
- package/src/discovery/local.ts +322 -0
- package/src/discovery/mcp-servers.ts +804 -0
- package/src/discovery/skills.ts +959 -0
- package/src/hooks/apply-patch/codec.test.ts +184 -0
- package/src/hooks/apply-patch/codec.ts +352 -0
- package/src/hooks/apply-patch/errors.ts +117 -0
- package/src/hooks/apply-patch/execution-context.ts +432 -0
- package/src/hooks/apply-patch/hook.test.ts +768 -0
- package/src/hooks/apply-patch/index.ts +126 -0
- package/src/hooks/apply-patch/matching.test.ts +215 -0
- package/src/hooks/apply-patch/matching.ts +586 -0
- package/src/hooks/apply-patch/operations.test.ts +1535 -0
- package/src/hooks/apply-patch/operations.ts +3 -0
- package/src/hooks/apply-patch/patch.ts +9 -0
- package/src/hooks/apply-patch/prepared-changes.ts +400 -0
- package/src/hooks/apply-patch/resolution.test.ts +420 -0
- package/src/hooks/apply-patch/resolution.ts +437 -0
- package/src/hooks/apply-patch/rewrite.ts +496 -0
- package/src/hooks/apply-patch/test-helpers.ts +52 -0
- package/src/hooks/apply-patch/types.ts +111 -0
- package/src/hooks/auto-update-checker/cache.test.ts +179 -0
- package/src/hooks/auto-update-checker/cache.ts +188 -0
- package/src/hooks/auto-update-checker/checker.test.ts +159 -0
- package/src/hooks/auto-update-checker/checker.ts +308 -0
- package/src/hooks/auto-update-checker/constants.ts +33 -0
- package/src/hooks/auto-update-checker/index.test.ts +282 -0
- package/src/hooks/auto-update-checker/index.ts +225 -0
- package/src/hooks/auto-update-checker/types.ts +26 -0
- package/src/hooks/chat-headers.test.ts +236 -0
- package/src/hooks/chat-headers.ts +97 -0
- package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
- package/src/hooks/context-pressure-reminder/index.ts +137 -0
- package/src/hooks/delegate-task-retry/guidance.ts +41 -0
- package/src/hooks/delegate-task-retry/hook.ts +23 -0
- package/src/hooks/delegate-task-retry/index.test.ts +38 -0
- package/src/hooks/delegate-task-retry/index.ts +7 -0
- package/src/hooks/delegate-task-retry/patterns.ts +79 -0
- package/src/hooks/filter-available-skills/index.test.ts +297 -0
- package/src/hooks/filter-available-skills/index.ts +160 -0
- package/src/hooks/foreground-fallback/index.test.ts +624 -0
- package/src/hooks/foreground-fallback/index.ts +374 -0
- package/src/hooks/image-hook.ts +6 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/json-error-recovery/hook.ts +73 -0
- package/src/hooks/json-error-recovery/index.test.ts +111 -0
- package/src/hooks/json-error-recovery/index.ts +6 -0
- package/src/hooks/phase-reminder/index.test.ts +74 -0
- package/src/hooks/phase-reminder/index.ts +85 -0
- package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
- package/src/hooks/post-file-tool-nudge/index.ts +63 -0
- package/src/hooks/task-session-manager/index.test.ts +833 -0
- package/src/hooks/task-session-manager/index.ts +434 -0
- package/src/hooks/todo-continuation/index.test.ts +3026 -0
- package/src/hooks/todo-continuation/index.ts +878 -0
- package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
- package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
- package/src/index.ts +1672 -0
- package/src/mcp/context7.ts +14 -0
- package/src/mcp/grep-app.ts +11 -0
- package/src/mcp/index.test.ts +96 -0
- package/src/mcp/index.ts +66 -0
- package/src/mcp/types.ts +16 -0
- package/src/mcp/websearch.ts +47 -0
- package/src/skills/codemap/README.md +60 -0
- package/src/skills/codemap/SKILL.md +174 -0
- package/src/skills/codemap/scripts/codemap.mjs +483 -0
- package/src/skills/codemap/scripts/codemap.test.ts +129 -0
- package/src/skills/registry.ts +218 -0
- package/src/skills/simplify/README.md +19 -0
- package/src/skills/simplify/SKILL.md +138 -0
- package/src/subscriptions/accounts-store.test.ts +236 -0
- package/src/subscriptions/accounts-store.ts +184 -0
- package/src/subscriptions/index.ts +30 -0
- package/src/subscriptions/neuralwatt-scraper.ts +108 -0
- package/src/subscriptions/opencode-go-scraper.ts +301 -0
- package/src/subscriptions/types.ts +145 -0
- package/src/subscriptions/usage-service.test.ts +202 -0
- package/src/subscriptions/usage-service.ts +651 -0
- package/src/tools/ast-grep/cli.ts +257 -0
- package/src/tools/ast-grep/constants.ts +214 -0
- package/src/tools/ast-grep/downloader.ts +131 -0
- package/src/tools/ast-grep/index.ts +24 -0
- package/src/tools/ast-grep/tools.ts +117 -0
- package/src/tools/ast-grep/types.ts +51 -0
- package/src/tools/ast-grep/utils.ts +126 -0
- package/src/tools/delegate-handoff.test.ts +18 -0
- package/src/tools/delegate.ts +508 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/preset-manager.test.ts +795 -0
- package/src/tools/preset-manager.ts +332 -0
- package/src/tools/smartfetch/binary.ts +58 -0
- package/src/tools/smartfetch/cache.test.ts +34 -0
- package/src/tools/smartfetch/cache.ts +112 -0
- package/src/tools/smartfetch/constants.ts +29 -0
- package/src/tools/smartfetch/index.ts +8 -0
- package/src/tools/smartfetch/network.test.ts +178 -0
- package/src/tools/smartfetch/network.ts +614 -0
- package/src/tools/smartfetch/secondary-model.test.ts +85 -0
- package/src/tools/smartfetch/secondary-model.ts +276 -0
- package/src/tools/smartfetch/tool.test.ts +60 -0
- package/src/tools/smartfetch/tool.ts +832 -0
- package/src/tools/smartfetch/types.ts +135 -0
- package/src/tools/smartfetch/utils.test.ts +24 -0
- package/src/tools/smartfetch/utils.ts +456 -0
- package/src/tui-state.test.ts +867 -0
- package/src/tui-state.ts +1255 -0
- package/src/tui.test.ts +336 -0
- package/src/tui.ts +1539 -0
- package/src/utils/agent-variant.test.ts +244 -0
- package/src/utils/agent-variant.ts +187 -0
- package/src/utils/compat.ts +91 -0
- package/src/utils/env.ts +12 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/internal-initiator.ts +28 -0
- package/src/utils/logger.test.ts +220 -0
- package/src/utils/logger.ts +136 -0
- package/src/utils/polling.test.ts +191 -0
- package/src/utils/polling.ts +67 -0
- package/src/utils/session-manager.test.ts +173 -0
- package/src/utils/session-manager.ts +356 -0
- package/src/utils/session.test.ts +110 -0
- package/src/utils/session.ts +389 -0
- package/src/utils/subagent-depth.test.ts +170 -0
- package/src/utils/subagent-depth.ts +75 -0
- package/src/utils/system-collapse.test.ts +86 -0
- package/src/utils/system-collapse.ts +24 -0
- package/src/utils/task.test.ts +24 -0
- package/src/utils/task.ts +20 -0
- package/src/utils/zip-extractor.ts +102 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { loadAgentPrompt, loadPluginConfig } from './loader';
|
|
6
|
+
|
|
7
|
+
// Test deepMerge indirectly through loadPluginConfig behavior
|
|
8
|
+
// since deepMerge is not exported
|
|
9
|
+
|
|
10
|
+
describe('loadPluginConfig', () => {
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
let userConfigDir: string;
|
|
13
|
+
let originalEnv: typeof process.env;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loader-test-'));
|
|
17
|
+
userConfigDir = path.join(tempDir, 'user-config');
|
|
18
|
+
originalEnv = { ...process.env };
|
|
19
|
+
// Isolate from real user config
|
|
20
|
+
delete process.env.OPENCODE_CONFIG_DIR;
|
|
21
|
+
process.env.XDG_CONFIG_HOME = userConfigDir;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
26
|
+
process.env = originalEnv;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('returns empty config when no config files exist', () => {
|
|
30
|
+
const projectDir = path.join(tempDir, 'project');
|
|
31
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
32
|
+
const config = loadPluginConfig(projectDir);
|
|
33
|
+
expect(config).toEqual({});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('loads project config from .opencode directory', () => {
|
|
37
|
+
const projectDir = path.join(tempDir, 'project');
|
|
38
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
39
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
agents: {
|
|
44
|
+
oracle: { model: 'test/model' },
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const config = loadPluginConfig(projectDir);
|
|
50
|
+
expect(config.agents?.oracle?.model).toBe('test/model');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('loads scoringEngineVersion flag when configured', () => {
|
|
54
|
+
const projectDir = path.join(tempDir, 'project');
|
|
55
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
56
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
57
|
+
fs.writeFileSync(
|
|
58
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
scoringEngineVersion: 'v2-shadow',
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const config = loadPluginConfig(projectDir);
|
|
65
|
+
expect(config.scoringEngineVersion).toBe('v2-shadow');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('loads balanceProviderUsage flag when configured', () => {
|
|
69
|
+
const projectDir = path.join(tempDir, 'project');
|
|
70
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
71
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
balanceProviderUsage: true,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const config = loadPluginConfig(projectDir);
|
|
80
|
+
expect(config.balanceProviderUsage).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('loads autoUpdate flag when configured', () => {
|
|
84
|
+
const projectDir = path.join(tempDir, 'project');
|
|
85
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
86
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
87
|
+
fs.writeFileSync(
|
|
88
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
autoUpdate: false,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const config = loadPluginConfig(projectDir);
|
|
95
|
+
expect(config.autoUpdate).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('loads manual plan structure when configured', () => {
|
|
99
|
+
const projectDir = path.join(tempDir, 'project');
|
|
100
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
101
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
102
|
+
fs.writeFileSync(
|
|
103
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
manualPlan: {
|
|
106
|
+
orchestrator: {
|
|
107
|
+
primary: 'openai/gpt-5.5',
|
|
108
|
+
fallback1: 'anthropic/claude-opus-4-6',
|
|
109
|
+
fallback2: 'chutes/kimi-k2.5',
|
|
110
|
+
fallback3: 'opencode/gpt-5-nano',
|
|
111
|
+
},
|
|
112
|
+
oracle: {
|
|
113
|
+
primary: 'openai/gpt-5.5',
|
|
114
|
+
fallback1: 'anthropic/claude-opus-4-6',
|
|
115
|
+
fallback2: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
|
|
116
|
+
fallback3: 'opencode/gpt-5-nano',
|
|
117
|
+
},
|
|
118
|
+
designer: {
|
|
119
|
+
primary: 'openai/gpt-5.5',
|
|
120
|
+
fallback1: 'anthropic/claude-opus-4-6',
|
|
121
|
+
fallback2: 'chutes/kimi-k2.5',
|
|
122
|
+
fallback3: 'opencode/gpt-5-nano',
|
|
123
|
+
},
|
|
124
|
+
explorer: {
|
|
125
|
+
primary: 'openai/gpt-5.5',
|
|
126
|
+
fallback1: 'anthropic/claude-opus-4-6',
|
|
127
|
+
fallback2: 'chutes/kimi-k2.5',
|
|
128
|
+
fallback3: 'opencode/gpt-5-nano',
|
|
129
|
+
},
|
|
130
|
+
librarian: {
|
|
131
|
+
primary: 'openai/gpt-5.5',
|
|
132
|
+
fallback1: 'anthropic/claude-opus-4-6',
|
|
133
|
+
fallback2: 'chutes/kimi-k2.5',
|
|
134
|
+
fallback3: 'opencode/gpt-5-nano',
|
|
135
|
+
},
|
|
136
|
+
fixer: {
|
|
137
|
+
primary: 'openai/gpt-5.5',
|
|
138
|
+
fallback1: 'anthropic/claude-opus-4-6',
|
|
139
|
+
fallback2: 'chutes/kimi-k2.5',
|
|
140
|
+
fallback3: 'opencode/gpt-5-nano',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const config = loadPluginConfig(projectDir);
|
|
147
|
+
expect(config.manualPlan?.oracle?.fallback2).toBe(
|
|
148
|
+
'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('ignores invalid config (schema violation or malformed JSON)', () => {
|
|
153
|
+
const projectDir = path.join(tempDir, 'project');
|
|
154
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
155
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
156
|
+
|
|
157
|
+
// Test 1: Invalid temperature (out of range)
|
|
158
|
+
fs.writeFileSync(
|
|
159
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
160
|
+
JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
|
|
161
|
+
);
|
|
162
|
+
expect(loadPluginConfig(projectDir)).toEqual({});
|
|
163
|
+
|
|
164
|
+
// Test 2: Malformed JSON
|
|
165
|
+
fs.writeFileSync(
|
|
166
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
167
|
+
'{ invalid json }',
|
|
168
|
+
);
|
|
169
|
+
expect(loadPluginConfig(projectDir)).toEqual({});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('respects OPENCODE_CONFIG_DIR for user config location', () => {
|
|
173
|
+
const customDir = fs.mkdtempSync(
|
|
174
|
+
path.join(os.tmpdir(), 'omc-opencode-config-'),
|
|
175
|
+
);
|
|
176
|
+
process.env.OPENCODE_CONFIG_DIR = customDir;
|
|
177
|
+
|
|
178
|
+
// Write plugin config in the custom directory
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
path.join(customDir, 'opencode-dux.json'),
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
agents: { oracle: { model: 'custom/model-from-opencode-config-dir' } },
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const projectDir = path.join(tempDir, 'project');
|
|
187
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
188
|
+
|
|
189
|
+
const config = loadPluginConfig(projectDir);
|
|
190
|
+
expect(config.agents?.oracle?.model).toBe(
|
|
191
|
+
'custom/model-from-opencode-config-dir',
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
fs.rmSync(customDir, { recursive: true, force: true });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('falls back to default user config dir when OPENCODE_CONFIG_DIR has no config', () => {
|
|
198
|
+
const customDir = fs.mkdtempSync(
|
|
199
|
+
path.join(os.tmpdir(), 'omc-opencode-config-empty-'),
|
|
200
|
+
);
|
|
201
|
+
process.env.OPENCODE_CONFIG_DIR = customDir;
|
|
202
|
+
|
|
203
|
+
const defaultConfigDir = path.join(userConfigDir, 'opencode');
|
|
204
|
+
fs.mkdirSync(defaultConfigDir, { recursive: true });
|
|
205
|
+
fs.writeFileSync(
|
|
206
|
+
path.join(defaultConfigDir, 'opencode-dux.json'),
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
agents: { oracle: { model: 'fallback/default-config' } },
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const projectDir = path.join(tempDir, 'project');
|
|
213
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
214
|
+
|
|
215
|
+
const config = loadPluginConfig(projectDir);
|
|
216
|
+
expect(config.agents?.oracle?.model).toBe('fallback/default-config');
|
|
217
|
+
|
|
218
|
+
fs.rmSync(customDir, { recursive: true, force: true });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('deepMerge behavior', () => {
|
|
223
|
+
let tempDir: string;
|
|
224
|
+
let userConfigDir: string;
|
|
225
|
+
let originalEnv: typeof process.env;
|
|
226
|
+
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-test-'));
|
|
229
|
+
userConfigDir = path.join(tempDir, 'user-config');
|
|
230
|
+
originalEnv = { ...process.env };
|
|
231
|
+
|
|
232
|
+
// Set XDG_CONFIG_HOME to control user config location
|
|
233
|
+
delete process.env.OPENCODE_CONFIG_DIR;
|
|
234
|
+
process.env.XDG_CONFIG_HOME = userConfigDir;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
239
|
+
process.env = originalEnv;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('merges nested agent configs from user and project', () => {
|
|
243
|
+
// Create user config
|
|
244
|
+
const userOpencodeDir = path.join(userConfigDir, 'opencode');
|
|
245
|
+
fs.mkdirSync(userOpencodeDir, { recursive: true });
|
|
246
|
+
fs.writeFileSync(
|
|
247
|
+
path.join(userOpencodeDir, 'opencode-dux.json'),
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
agents: {
|
|
250
|
+
oracle: { model: 'user/oracle-model', temperature: 0.5 },
|
|
251
|
+
explorer: { model: 'user/explorer-model' },
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Create project config (should override/merge with user)
|
|
257
|
+
const projectDir = path.join(tempDir, 'project');
|
|
258
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
259
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
260
|
+
fs.writeFileSync(
|
|
261
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
agents: {
|
|
264
|
+
oracle: { temperature: 0.8 }, // Override temperature only
|
|
265
|
+
designer: { model: 'project/designer-model' }, // Add new agent
|
|
266
|
+
},
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const config = loadPluginConfig(projectDir);
|
|
271
|
+
|
|
272
|
+
// oracle: model from user, temperature from project
|
|
273
|
+
expect(config.agents?.oracle?.model).toBe('user/oracle-model');
|
|
274
|
+
expect(config.agents?.oracle?.temperature).toBe(0.8);
|
|
275
|
+
|
|
276
|
+
// explorer: from user only
|
|
277
|
+
expect(config.agents?.explorer?.model).toBe('user/explorer-model');
|
|
278
|
+
|
|
279
|
+
// designer: from project only
|
|
280
|
+
expect(config.agents?.designer?.model).toBe('project/designer-model');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('handles missing user config gracefully', () => {
|
|
284
|
+
// Don't create user config, only project
|
|
285
|
+
const projectDir = path.join(tempDir, 'project');
|
|
286
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
287
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
288
|
+
fs.writeFileSync(
|
|
289
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
290
|
+
JSON.stringify({
|
|
291
|
+
agents: {
|
|
292
|
+
oracle: { model: 'project/model' },
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const config = loadPluginConfig(projectDir);
|
|
298
|
+
expect(config.agents?.oracle?.model).toBe('project/model');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('handles missing project config gracefully', () => {
|
|
302
|
+
const userOpencodeDir = path.join(userConfigDir, 'opencode');
|
|
303
|
+
fs.mkdirSync(userOpencodeDir, { recursive: true });
|
|
304
|
+
fs.writeFileSync(
|
|
305
|
+
path.join(userOpencodeDir, 'opencode-dux.json'),
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
agents: {
|
|
308
|
+
oracle: { model: 'user/model' },
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// No project config
|
|
314
|
+
const projectDir = path.join(tempDir, 'project');
|
|
315
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
316
|
+
|
|
317
|
+
const config = loadPluginConfig(projectDir);
|
|
318
|
+
expect(config.agents?.oracle?.model).toBe('user/model');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('merges fallback timeout and chains from user and project', () => {
|
|
322
|
+
const userOpencodeDir = path.join(userConfigDir, 'opencode');
|
|
323
|
+
fs.mkdirSync(userOpencodeDir, { recursive: true });
|
|
324
|
+
fs.writeFileSync(
|
|
325
|
+
path.join(userOpencodeDir, 'opencode-dux.json'),
|
|
326
|
+
JSON.stringify({
|
|
327
|
+
fallback: {
|
|
328
|
+
timeoutMs: 15000,
|
|
329
|
+
chains: {
|
|
330
|
+
oracle: ['openai/gpt-5.5', 'opencode/glm-4.7-free'],
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const projectDir = path.join(tempDir, 'project');
|
|
337
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
338
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
339
|
+
fs.writeFileSync(
|
|
340
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
341
|
+
JSON.stringify({
|
|
342
|
+
fallback: {
|
|
343
|
+
chains: {
|
|
344
|
+
explorer: ['google/antigravity-gemini-3-flash'],
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const config = loadPluginConfig(projectDir);
|
|
351
|
+
expect(config.fallback?.timeoutMs).toBe(15000);
|
|
352
|
+
expect(config.fallback?.chains.oracle).toEqual([
|
|
353
|
+
'openai/gpt-5.5',
|
|
354
|
+
'opencode/glm-4.7-free',
|
|
355
|
+
]);
|
|
356
|
+
expect(config.fallback?.chains.explorer).toEqual([
|
|
357
|
+
'google/antigravity-gemini-3-flash',
|
|
358
|
+
]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('preserves fallback chains with additional agent keys', () => {
|
|
362
|
+
const projectDir = path.join(tempDir, 'project');
|
|
363
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
364
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
365
|
+
fs.writeFileSync(
|
|
366
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
367
|
+
JSON.stringify({
|
|
368
|
+
fallback: {
|
|
369
|
+
chains: {
|
|
370
|
+
writing: ['openai/gpt-5.5'],
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const config = loadPluginConfig(projectDir);
|
|
377
|
+
expect(config.fallback?.chains.writing).toEqual(['openai/gpt-5.5']);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe('preset resolution', () => {
|
|
382
|
+
let tempDir: string;
|
|
383
|
+
let originalEnv: typeof process.env;
|
|
384
|
+
|
|
385
|
+
beforeEach(() => {
|
|
386
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-test-'));
|
|
387
|
+
originalEnv = { ...process.env };
|
|
388
|
+
delete process.env.OPENCODE_CONFIG_DIR;
|
|
389
|
+
process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
afterEach(() => {
|
|
393
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
394
|
+
process.env = originalEnv;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('backward compatibility: config with only agents works unchanged', () => {
|
|
398
|
+
const projectDir = path.join(tempDir, 'project');
|
|
399
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
400
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
401
|
+
fs.writeFileSync(
|
|
402
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
403
|
+
JSON.stringify({
|
|
404
|
+
agents: { oracle: { model: 'direct-model' } },
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const config = loadPluginConfig(projectDir);
|
|
409
|
+
expect(config.agents?.oracle?.model).toBe('direct-model');
|
|
410
|
+
expect(config.preset).toBeUndefined();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("preset applied: preset + presets returns preset's agents", () => {
|
|
414
|
+
const projectDir = path.join(tempDir, 'project');
|
|
415
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
416
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
417
|
+
fs.writeFileSync(
|
|
418
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
419
|
+
JSON.stringify({
|
|
420
|
+
preset: 'fast',
|
|
421
|
+
presets: {
|
|
422
|
+
fast: { oracle: { model: 'fast-model' } },
|
|
423
|
+
},
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const config = loadPluginConfig(projectDir);
|
|
428
|
+
expect(config.agents?.oracle?.model).toBe('fast-model');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('root agents override preset agents', () => {
|
|
432
|
+
const projectDir = path.join(tempDir, 'project');
|
|
433
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
434
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
435
|
+
fs.writeFileSync(
|
|
436
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
437
|
+
JSON.stringify({
|
|
438
|
+
preset: 'fast',
|
|
439
|
+
presets: {
|
|
440
|
+
fast: {
|
|
441
|
+
oracle: { model: 'fast-model', temperature: 0.1 },
|
|
442
|
+
explorer: { model: 'explorer-model' },
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
agents: {
|
|
446
|
+
oracle: { temperature: 0.9 }, // Should override preset temperature
|
|
447
|
+
},
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const config = loadPluginConfig(projectDir);
|
|
452
|
+
expect(config.agents?.oracle?.model).toBe('fast-model');
|
|
453
|
+
expect(config.agents?.oracle?.temperature).toBe(0.9);
|
|
454
|
+
expect(config.agents?.explorer?.model).toBe('explorer-model');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('missing preset: preset set but not in presets -> returns empty/root agents', () => {
|
|
458
|
+
const projectDir = path.join(tempDir, 'project');
|
|
459
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
460
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
461
|
+
fs.writeFileSync(
|
|
462
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
463
|
+
JSON.stringify({
|
|
464
|
+
preset: 'nonexistent',
|
|
465
|
+
presets: {
|
|
466
|
+
other: { oracle: { model: 'other' } },
|
|
467
|
+
},
|
|
468
|
+
agents: { oracle: { model: 'root' } },
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const config = loadPluginConfig(projectDir);
|
|
473
|
+
expect(config.agents?.oracle?.model).toBe('root');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('preset only: no root agents, just preset works', () => {
|
|
477
|
+
const projectDir = path.join(tempDir, 'project');
|
|
478
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
479
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
480
|
+
fs.writeFileSync(
|
|
481
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
482
|
+
JSON.stringify({
|
|
483
|
+
preset: 'dev',
|
|
484
|
+
presets: {
|
|
485
|
+
dev: { oracle: { model: 'dev-model' } },
|
|
486
|
+
},
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const config = loadPluginConfig(projectDir);
|
|
491
|
+
expect(config.agents?.oracle?.model).toBe('dev-model');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('invalid preset shape: bad agent config in preset fails schema validation', () => {
|
|
495
|
+
const projectDir = path.join(tempDir, 'project');
|
|
496
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
497
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
498
|
+
|
|
499
|
+
// preset agents with invalid temperature
|
|
500
|
+
fs.writeFileSync(
|
|
501
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
502
|
+
JSON.stringify({
|
|
503
|
+
preset: 'invalid',
|
|
504
|
+
presets: {
|
|
505
|
+
invalid: { oracle: { temperature: 5 } },
|
|
506
|
+
},
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// Should return empty config due to validation failure
|
|
511
|
+
expect(loadPluginConfig(projectDir)).toEqual({});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('nonexistent preset from config warns and falls back to root agents', () => {
|
|
515
|
+
const projectDir = path.join(tempDir, 'project');
|
|
516
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
517
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
518
|
+
fs.writeFileSync(
|
|
519
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
520
|
+
JSON.stringify({
|
|
521
|
+
preset: 'nonexistent',
|
|
522
|
+
presets: {
|
|
523
|
+
other: { oracle: { model: 'other' } },
|
|
524
|
+
},
|
|
525
|
+
agents: { oracle: { model: 'root' } },
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
const consoleWarnSpy = spyOn(console, 'warn');
|
|
530
|
+
const config = loadPluginConfig(projectDir);
|
|
531
|
+
expect(config.agents?.oracle?.model).toBe('root');
|
|
532
|
+
expect(consoleWarnSpy).toHaveBeenCalled();
|
|
533
|
+
const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
|
|
534
|
+
expect(warningMessage).toContain('Preset "nonexistent" not found');
|
|
535
|
+
expect(warningMessage).toContain('Available presets: other');
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test('nonexistent preset with no root agents returns empty agents', () => {
|
|
539
|
+
const projectDir = path.join(tempDir, 'project');
|
|
540
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
541
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
542
|
+
fs.writeFileSync(
|
|
543
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
544
|
+
JSON.stringify({
|
|
545
|
+
preset: 'nonexistent',
|
|
546
|
+
presets: {
|
|
547
|
+
other: { oracle: { model: 'other' } },
|
|
548
|
+
},
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const consoleWarnSpy = spyOn(console, 'warn');
|
|
553
|
+
const config = loadPluginConfig(projectDir);
|
|
554
|
+
expect(config.agents).toBeUndefined();
|
|
555
|
+
expect(consoleWarnSpy).toHaveBeenCalled();
|
|
556
|
+
const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
|
|
557
|
+
expect(warningMessage).toContain('Preset "nonexistent" not found');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('options from preset are deep-merged with root agents', () => {
|
|
561
|
+
const projectDir = path.join(tempDir, 'project');
|
|
562
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
563
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
564
|
+
fs.writeFileSync(
|
|
565
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
566
|
+
JSON.stringify({
|
|
567
|
+
preset: 'openai',
|
|
568
|
+
presets: {
|
|
569
|
+
openai: {
|
|
570
|
+
oracle: {
|
|
571
|
+
model: 'openai/gpt-5.5',
|
|
572
|
+
options: { textVerbosity: 'low' },
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
agents: {
|
|
577
|
+
oracle: {
|
|
578
|
+
options: { reasoningEffort: 'medium' },
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
}),
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
const config = loadPluginConfig(projectDir);
|
|
585
|
+
expect(config.agents?.oracle?.model).toBe('openai/gpt-5.5');
|
|
586
|
+
// deepMerge should combine both option keys
|
|
587
|
+
expect(config.agents?.oracle?.options).toEqual({
|
|
588
|
+
textVerbosity: 'low',
|
|
589
|
+
reasoningEffort: 'medium',
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test('options from preset only work without root agents', () => {
|
|
594
|
+
const projectDir = path.join(tempDir, 'project');
|
|
595
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
596
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
597
|
+
fs.writeFileSync(
|
|
598
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
599
|
+
JSON.stringify({
|
|
600
|
+
preset: 'anthropic-thinking',
|
|
601
|
+
presets: {
|
|
602
|
+
'anthropic-thinking': {
|
|
603
|
+
oracle: {
|
|
604
|
+
model: 'anthropic/claude-sonnet-4-6',
|
|
605
|
+
options: {
|
|
606
|
+
thinking: { type: 'enabled', budgetTokens: 16000 },
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const config = loadPluginConfig(projectDir);
|
|
615
|
+
expect(config.agents?.oracle?.model).toBe('anthropic/claude-sonnet-4-6');
|
|
616
|
+
expect(config.agents?.oracle?.options).toEqual({
|
|
617
|
+
thinking: { type: 'enabled', budgetTokens: 16000 },
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test('root options override preset options for same key', () => {
|
|
622
|
+
const projectDir = path.join(tempDir, 'project');
|
|
623
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
624
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
625
|
+
fs.writeFileSync(
|
|
626
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
627
|
+
JSON.stringify({
|
|
628
|
+
preset: 'concise',
|
|
629
|
+
presets: {
|
|
630
|
+
concise: {
|
|
631
|
+
oracle: {
|
|
632
|
+
model: 'openai/gpt-5.5',
|
|
633
|
+
options: { textVerbosity: 'low' },
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
agents: {
|
|
638
|
+
oracle: {
|
|
639
|
+
options: { textVerbosity: 'high' },
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
const config = loadPluginConfig(projectDir);
|
|
646
|
+
expect(config.agents?.oracle?.model).toBe('openai/gpt-5.5');
|
|
647
|
+
// root wins over preset for same key
|
|
648
|
+
expect(config.agents?.oracle?.options).toEqual({
|
|
649
|
+
textVerbosity: 'high',
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
describe('environment variable preset override', () => {
|
|
655
|
+
let tempDir: string;
|
|
656
|
+
let originalEnv: typeof process.env;
|
|
657
|
+
|
|
658
|
+
beforeEach(() => {
|
|
659
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-preset-test-'));
|
|
660
|
+
originalEnv = { ...process.env };
|
|
661
|
+
delete process.env.OPENCODE_CONFIG_DIR;
|
|
662
|
+
process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
afterEach(() => {
|
|
666
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
667
|
+
process.env = originalEnv;
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test('Env var overrides preset from config file', () => {
|
|
671
|
+
const projectDir = path.join(tempDir, 'project');
|
|
672
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
673
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
674
|
+
fs.writeFileSync(
|
|
675
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
676
|
+
JSON.stringify({
|
|
677
|
+
preset: 'config-preset',
|
|
678
|
+
presets: {
|
|
679
|
+
'config-preset': { oracle: { model: 'config-model' } },
|
|
680
|
+
'env-preset': { oracle: { model: 'env-model' } },
|
|
681
|
+
},
|
|
682
|
+
}),
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
|
|
686
|
+
const config = loadPluginConfig(projectDir);
|
|
687
|
+
expect(config.preset).toBe('env-preset');
|
|
688
|
+
expect(config.agents?.oracle?.model).toBe('env-model');
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test('Env var works when config has no preset', () => {
|
|
692
|
+
const projectDir = path.join(tempDir, 'project');
|
|
693
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
694
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
695
|
+
fs.writeFileSync(
|
|
696
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
697
|
+
JSON.stringify({
|
|
698
|
+
presets: {
|
|
699
|
+
'env-preset': { oracle: { model: 'env-model' } },
|
|
700
|
+
},
|
|
701
|
+
}),
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
|
|
705
|
+
const config = loadPluginConfig(projectDir);
|
|
706
|
+
expect(config.preset).toBe('env-preset');
|
|
707
|
+
expect(config.agents?.oracle?.model).toBe('env-model');
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test('Env var is ignored if empty string', () => {
|
|
711
|
+
const projectDir = path.join(tempDir, 'project');
|
|
712
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
713
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
714
|
+
fs.writeFileSync(
|
|
715
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
716
|
+
JSON.stringify({
|
|
717
|
+
preset: 'config-preset',
|
|
718
|
+
presets: {
|
|
719
|
+
'config-preset': { oracle: { model: 'config-model' } },
|
|
720
|
+
},
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
process.env.OH_MY_OPENCODE_SLIM_PRESET = '';
|
|
725
|
+
const config = loadPluginConfig(projectDir);
|
|
726
|
+
expect(config.preset).toBe('config-preset');
|
|
727
|
+
expect(config.agents?.oracle?.model).toBe('config-model');
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test('Env var is ignored if undefined', () => {
|
|
731
|
+
const projectDir = path.join(tempDir, 'project');
|
|
732
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
733
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
734
|
+
fs.writeFileSync(
|
|
735
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
736
|
+
JSON.stringify({
|
|
737
|
+
preset: 'config-preset',
|
|
738
|
+
presets: {
|
|
739
|
+
'config-preset': { oracle: { model: 'config-model' } },
|
|
740
|
+
},
|
|
741
|
+
}),
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
|
|
745
|
+
const config = loadPluginConfig(projectDir);
|
|
746
|
+
expect(config.preset).toBe('config-preset');
|
|
747
|
+
expect(config.agents?.oracle?.model).toBe('config-model');
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test('Env var with nonexistent preset warns and falls back', () => {
|
|
751
|
+
const projectDir = path.join(tempDir, 'project');
|
|
752
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
753
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
754
|
+
fs.writeFileSync(
|
|
755
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
756
|
+
JSON.stringify({
|
|
757
|
+
preset: 'config-preset',
|
|
758
|
+
presets: {
|
|
759
|
+
'config-preset': { oracle: { model: 'config-model' } },
|
|
760
|
+
},
|
|
761
|
+
agents: { oracle: { model: 'fallback' } },
|
|
762
|
+
}),
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
process.env.OH_MY_OPENCODE_SLIM_PRESET = 'typo-preset';
|
|
766
|
+
const consoleWarnSpy = spyOn(console, 'warn');
|
|
767
|
+
const config = loadPluginConfig(projectDir);
|
|
768
|
+
expect(config.preset).toBe('typo-preset');
|
|
769
|
+
expect(config.agents?.oracle?.model).toBe('fallback');
|
|
770
|
+
expect(consoleWarnSpy).toHaveBeenCalled();
|
|
771
|
+
const calls = consoleWarnSpy.mock.calls as string[][];
|
|
772
|
+
const warningMessage =
|
|
773
|
+
calls.find((call) => call[0]?.includes('typo-preset'))?.[0] || '';
|
|
774
|
+
expect(warningMessage).toContain('Preset "typo-preset" not found');
|
|
775
|
+
expect(warningMessage).toContain('environment variable');
|
|
776
|
+
expect(warningMessage).toContain('config-preset');
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
describe('JSONC config support', () => {
|
|
781
|
+
let tempDir: string;
|
|
782
|
+
let originalEnv: typeof process.env;
|
|
783
|
+
|
|
784
|
+
beforeEach(() => {
|
|
785
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonc-test-'));
|
|
786
|
+
originalEnv = { ...process.env };
|
|
787
|
+
delete process.env.OPENCODE_CONFIG_DIR;
|
|
788
|
+
process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
afterEach(() => {
|
|
792
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
793
|
+
process.env = originalEnv;
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('loads .jsonc file with single-line comments', () => {
|
|
797
|
+
const projectDir = path.join(tempDir, 'project');
|
|
798
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
799
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
800
|
+
fs.writeFileSync(
|
|
801
|
+
path.join(projectConfigDir, 'opencode-dux.jsonc'),
|
|
802
|
+
`{
|
|
803
|
+
// This is a comment
|
|
804
|
+
"agents": {
|
|
805
|
+
"oracle": { "model": "test/model" } // inline comment
|
|
806
|
+
}
|
|
807
|
+
}`,
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
const config = loadPluginConfig(projectDir);
|
|
811
|
+
expect(config.agents?.oracle?.model).toBe('test/model');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test('loads .jsonc file with multi-line comments', () => {
|
|
815
|
+
const projectDir = path.join(tempDir, 'project');
|
|
816
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
817
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
818
|
+
fs.writeFileSync(
|
|
819
|
+
path.join(projectConfigDir, 'opencode-dux.jsonc'),
|
|
820
|
+
`{
|
|
821
|
+
/* Multi-line
|
|
822
|
+
comment block */
|
|
823
|
+
"agents": {
|
|
824
|
+
"explorer": { "model": "explorer-model" }
|
|
825
|
+
}
|
|
826
|
+
}`,
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const config = loadPluginConfig(projectDir);
|
|
830
|
+
expect(config.agents?.explorer?.model).toBe('explorer-model');
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test('loads .jsonc file with trailing commas', () => {
|
|
834
|
+
const projectDir = path.join(tempDir, 'project');
|
|
835
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
836
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
837
|
+
fs.writeFileSync(
|
|
838
|
+
path.join(projectConfigDir, 'opencode-dux.jsonc'),
|
|
839
|
+
`{
|
|
840
|
+
"agents": {
|
|
841
|
+
"oracle": { "model": "test-model", },
|
|
842
|
+
},
|
|
843
|
+
}`,
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
const config = loadPluginConfig(projectDir);
|
|
847
|
+
expect(config.agents?.oracle?.model).toBe('test-model');
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test('prefers .jsonc over .json when both exist', () => {
|
|
851
|
+
const projectDir = path.join(tempDir, 'project');
|
|
852
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
853
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
854
|
+
|
|
855
|
+
// Create both files
|
|
856
|
+
fs.writeFileSync(
|
|
857
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
858
|
+
JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
|
|
859
|
+
);
|
|
860
|
+
fs.writeFileSync(
|
|
861
|
+
path.join(projectConfigDir, 'opencode-dux.jsonc'),
|
|
862
|
+
`{
|
|
863
|
+
// JSONC version
|
|
864
|
+
"agents": { "oracle": { "model": "jsonc-model" } }
|
|
865
|
+
}`,
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const config = loadPluginConfig(projectDir);
|
|
869
|
+
expect(config.agents?.oracle?.model).toBe('jsonc-model');
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test('falls back to .json when .jsonc does not exist', () => {
|
|
873
|
+
const projectDir = path.join(tempDir, 'project');
|
|
874
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
875
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
876
|
+
|
|
877
|
+
// Only create .json file
|
|
878
|
+
fs.writeFileSync(
|
|
879
|
+
path.join(projectConfigDir, 'opencode-dux.json'),
|
|
880
|
+
JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
const config = loadPluginConfig(projectDir);
|
|
884
|
+
expect(config.agents?.oracle?.model).toBe('json-model');
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
test('loads user config from .jsonc', () => {
|
|
888
|
+
const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
|
|
889
|
+
fs.mkdirSync(userOpencodeDir, { recursive: true });
|
|
890
|
+
fs.writeFileSync(
|
|
891
|
+
path.join(userOpencodeDir, 'opencode-dux.jsonc'),
|
|
892
|
+
`{
|
|
893
|
+
// User config with comments
|
|
894
|
+
"agents": { "librarian": { "model": "user-librarian" } }
|
|
895
|
+
}`,
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
const projectDir = path.join(tempDir, 'project');
|
|
899
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
900
|
+
|
|
901
|
+
const config = loadPluginConfig(projectDir);
|
|
902
|
+
expect(config.agents?.librarian?.model).toBe('user-librarian');
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
test('merges user .jsonc with project .jsonc', () => {
|
|
906
|
+
const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
|
|
907
|
+
fs.mkdirSync(userOpencodeDir, { recursive: true });
|
|
908
|
+
fs.writeFileSync(
|
|
909
|
+
path.join(userOpencodeDir, 'opencode-dux.jsonc'),
|
|
910
|
+
`{
|
|
911
|
+
// User config
|
|
912
|
+
"agents": {
|
|
913
|
+
"oracle": { "model": "user-oracle", "temperature": 0.5 }
|
|
914
|
+
}
|
|
915
|
+
}`,
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
const projectDir = path.join(tempDir, 'project');
|
|
919
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
920
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
921
|
+
fs.writeFileSync(
|
|
922
|
+
path.join(projectConfigDir, 'opencode-dux.jsonc'),
|
|
923
|
+
`{
|
|
924
|
+
// Project config
|
|
925
|
+
"agents": { "oracle": { "temperature": 0.8 } }
|
|
926
|
+
}`,
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
const config = loadPluginConfig(projectDir);
|
|
930
|
+
expect(config.agents?.oracle?.model).toBe('user-oracle');
|
|
931
|
+
expect(config.agents?.oracle?.temperature).toBe(0.8);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test('handles complex JSONC with mixed comments and trailing commas', () => {
|
|
935
|
+
const projectDir = path.join(tempDir, 'project');
|
|
936
|
+
const projectConfigDir = path.join(projectDir, '.opencode');
|
|
937
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
938
|
+
fs.writeFileSync(
|
|
939
|
+
path.join(projectConfigDir, 'opencode-dux.jsonc'),
|
|
940
|
+
`{
|
|
941
|
+
// Main configuration
|
|
942
|
+
"preset": "dev",
|
|
943
|
+
/* Presets definition */
|
|
944
|
+
"presets": {
|
|
945
|
+
"dev": {
|
|
946
|
+
// Development agents
|
|
947
|
+
"oracle": { "model": "dev-oracle", },
|
|
948
|
+
"explorer": { "model": "dev-explorer", },
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
}`,
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
const config = loadPluginConfig(projectDir);
|
|
955
|
+
expect(config.preset).toBe('dev');
|
|
956
|
+
expect(config.agents?.oracle?.model).toBe('dev-oracle');
|
|
957
|
+
expect(config.agents?.explorer?.model).toBe('dev-explorer');
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
describe('loadAgentPrompt', () => {
|
|
962
|
+
let tempDir: string;
|
|
963
|
+
let originalEnv: typeof process.env;
|
|
964
|
+
|
|
965
|
+
beforeEach(() => {
|
|
966
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-test-'));
|
|
967
|
+
originalEnv = { ...process.env };
|
|
968
|
+
delete process.env.OPENCODE_CONFIG_DIR;
|
|
969
|
+
process.env.XDG_CONFIG_HOME = tempDir;
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
afterEach(() => {
|
|
973
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
974
|
+
process.env = originalEnv;
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
test('returns empty object when no prompt files exist', () => {
|
|
978
|
+
const result = loadAgentPrompt('oracle');
|
|
979
|
+
expect(result).toEqual({});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test('loads replacement prompt from {agent}.md', () => {
|
|
983
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
984
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
985
|
+
fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
|
|
986
|
+
|
|
987
|
+
const result = loadAgentPrompt('oracle');
|
|
988
|
+
expect(result.prompt).toBe('replacement prompt');
|
|
989
|
+
expect(result.appendPrompt).toBeUndefined();
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test('loads append prompt from {agent}_append.md', () => {
|
|
993
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
994
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
995
|
+
fs.writeFileSync(
|
|
996
|
+
path.join(promptsDir, 'oracle_append.md'),
|
|
997
|
+
'append prompt',
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
const result = loadAgentPrompt('oracle');
|
|
1001
|
+
expect(result.prompt).toBeUndefined();
|
|
1002
|
+
expect(result.appendPrompt).toBe('append prompt');
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test('loads both replacement and append prompts', () => {
|
|
1006
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
1007
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
1008
|
+
fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
|
|
1009
|
+
fs.writeFileSync(
|
|
1010
|
+
path.join(promptsDir, 'oracle_append.md'),
|
|
1011
|
+
'append prompt',
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
const result = loadAgentPrompt('oracle');
|
|
1015
|
+
expect(result.prompt).toBe('replacement prompt');
|
|
1016
|
+
expect(result.appendPrompt).toBe('append prompt');
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
test('handles file read errors gracefully', () => {
|
|
1020
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
1021
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
1022
|
+
const promptPath = path.join(promptsDir, 'error-agent.md');
|
|
1023
|
+
fs.writeFileSync(promptPath, 'content');
|
|
1024
|
+
|
|
1025
|
+
const consoleWarnSpy = spyOn(console, 'warn');
|
|
1026
|
+
|
|
1027
|
+
// Use a unique agent name and check for it specifically
|
|
1028
|
+
const originalReadFileSync = fs.readFileSync;
|
|
1029
|
+
const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
|
|
1030
|
+
...args: Parameters<typeof fs.readFileSync>
|
|
1031
|
+
) => {
|
|
1032
|
+
const [p] = args;
|
|
1033
|
+
if (typeof p === 'string' && p.includes('error-agent.md')) {
|
|
1034
|
+
throw new Error('Read error');
|
|
1035
|
+
}
|
|
1036
|
+
return originalReadFileSync(...args);
|
|
1037
|
+
}) as typeof fs.readFileSync);
|
|
1038
|
+
|
|
1039
|
+
try {
|
|
1040
|
+
const result = loadAgentPrompt('error-agent');
|
|
1041
|
+
expect(result.prompt).toBeUndefined();
|
|
1042
|
+
|
|
1043
|
+
const warningFound = consoleWarnSpy.mock.calls.some((call) =>
|
|
1044
|
+
(call[0] as string).includes('Error reading prompt file'),
|
|
1045
|
+
);
|
|
1046
|
+
expect(warningFound).toBe(true);
|
|
1047
|
+
} finally {
|
|
1048
|
+
readSpy.mockRestore();
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
test('prefers preset prompt files over root prompts', () => {
|
|
1053
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
1054
|
+
const presetDir = path.join(promptsDir, 'test');
|
|
1055
|
+
fs.mkdirSync(presetDir, { recursive: true });
|
|
1056
|
+
|
|
1057
|
+
fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
|
|
1058
|
+
fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
|
|
1059
|
+
fs.writeFileSync(
|
|
1060
|
+
path.join(promptsDir, 'oracle_append.md'),
|
|
1061
|
+
'root append prompt',
|
|
1062
|
+
);
|
|
1063
|
+
fs.writeFileSync(
|
|
1064
|
+
path.join(presetDir, 'oracle_append.md'),
|
|
1065
|
+
'preset append prompt',
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
const result = loadAgentPrompt('oracle', 'test');
|
|
1069
|
+
expect(result.prompt).toBe('preset replacement');
|
|
1070
|
+
expect(result.appendPrompt).toBe('preset append prompt');
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
test('falls back to root prompt files when preset files are missing', () => {
|
|
1074
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
1075
|
+
const presetDir = path.join(promptsDir, 'test');
|
|
1076
|
+
fs.mkdirSync(presetDir, { recursive: true });
|
|
1077
|
+
|
|
1078
|
+
fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
|
|
1079
|
+
fs.writeFileSync(
|
|
1080
|
+
path.join(promptsDir, 'oracle_append.md'),
|
|
1081
|
+
'root append prompt',
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
const result = loadAgentPrompt('oracle', 'test');
|
|
1085
|
+
expect(result.prompt).toBe('root replacement');
|
|
1086
|
+
expect(result.appendPrompt).toBe('root append prompt');
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
test('falls back independently between preset and root files', () => {
|
|
1090
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
1091
|
+
const presetDir = path.join(promptsDir, 'test');
|
|
1092
|
+
fs.mkdirSync(presetDir, { recursive: true });
|
|
1093
|
+
|
|
1094
|
+
fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
|
|
1095
|
+
fs.writeFileSync(
|
|
1096
|
+
path.join(promptsDir, 'oracle_append.md'),
|
|
1097
|
+
'root append prompt',
|
|
1098
|
+
);
|
|
1099
|
+
|
|
1100
|
+
const result = loadAgentPrompt('oracle', 'test');
|
|
1101
|
+
expect(result.prompt).toBe('preset replacement');
|
|
1102
|
+
expect(result.appendPrompt).toBe('root append prompt');
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test('ignores unsafe preset names for prompt lookup', () => {
|
|
1106
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
1107
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
1108
|
+
fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
|
|
1109
|
+
|
|
1110
|
+
const result = loadAgentPrompt('oracle', '../test');
|
|
1111
|
+
expect(result.prompt).toBe('root replacement');
|
|
1112
|
+
expect(result.appendPrompt).toBeUndefined();
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test('falls back to root when preset prompt file read fails', () => {
|
|
1116
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
1117
|
+
const presetDir = path.join(promptsDir, 'test');
|
|
1118
|
+
fs.mkdirSync(presetDir, { recursive: true });
|
|
1119
|
+
const presetPromptPath = path.join(presetDir, 'oracle.md');
|
|
1120
|
+
fs.writeFileSync(presetPromptPath, 'preset replacement');
|
|
1121
|
+
fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
|
|
1122
|
+
|
|
1123
|
+
const consoleWarnSpy = spyOn(console, 'warn');
|
|
1124
|
+
const originalReadFileSync = fs.readFileSync;
|
|
1125
|
+
const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
|
|
1126
|
+
...args: Parameters<typeof fs.readFileSync>
|
|
1127
|
+
) => {
|
|
1128
|
+
const [p] = args;
|
|
1129
|
+
if (typeof p === 'string' && p === presetPromptPath) {
|
|
1130
|
+
throw new Error('Preset read error');
|
|
1131
|
+
}
|
|
1132
|
+
return originalReadFileSync(...args);
|
|
1133
|
+
}) as typeof fs.readFileSync);
|
|
1134
|
+
|
|
1135
|
+
try {
|
|
1136
|
+
const result = loadAgentPrompt('oracle', 'test');
|
|
1137
|
+
expect(result.prompt).toBe('root replacement');
|
|
1138
|
+
expect(consoleWarnSpy).toHaveBeenCalled();
|
|
1139
|
+
} finally {
|
|
1140
|
+
readSpy.mockRestore();
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
test('works with XDG_CONFIG_HOME environment variable', () => {
|
|
1145
|
+
const customConfigHome = path.join(tempDir, 'custom-xdg');
|
|
1146
|
+
process.env.XDG_CONFIG_HOME = customConfigHome;
|
|
1147
|
+
|
|
1148
|
+
const promptsDir = path.join(
|
|
1149
|
+
customConfigHome,
|
|
1150
|
+
'opencode',
|
|
1151
|
+
'opencode-dux',
|
|
1152
|
+
);
|
|
1153
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
1154
|
+
fs.writeFileSync(path.join(promptsDir, 'xdg-agent.md'), 'xdg prompt');
|
|
1155
|
+
|
|
1156
|
+
const result = loadAgentPrompt('xdg-agent');
|
|
1157
|
+
expect(result.prompt).toBe('xdg prompt');
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
test('respects OPENCODE_CONFIG_DIR for prompt location', () => {
|
|
1161
|
+
const customDir = fs.mkdtempSync(
|
|
1162
|
+
path.join(os.tmpdir(), 'omc-prompt-config-'),
|
|
1163
|
+
);
|
|
1164
|
+
process.env.OPENCODE_CONFIG_DIR = customDir;
|
|
1165
|
+
|
|
1166
|
+
const promptsDir = path.join(customDir, 'opencode-dux');
|
|
1167
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
1168
|
+
fs.writeFileSync(
|
|
1169
|
+
path.join(promptsDir, 'oracle.md'),
|
|
1170
|
+
'prompt from OPENCODE_CONFIG_DIR dir',
|
|
1171
|
+
);
|
|
1172
|
+
|
|
1173
|
+
const result = loadAgentPrompt('oracle');
|
|
1174
|
+
expect(result.prompt).toBe('prompt from OPENCODE_CONFIG_DIR dir');
|
|
1175
|
+
|
|
1176
|
+
fs.rmSync(customDir, { recursive: true, force: true });
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
test('falls back to default prompt dir when OPENCODE_CONFIG_DIR has no prompt', () => {
|
|
1180
|
+
const customDir = fs.mkdtempSync(
|
|
1181
|
+
path.join(os.tmpdir(), 'omc-prompt-config-empty-'),
|
|
1182
|
+
);
|
|
1183
|
+
process.env.OPENCODE_CONFIG_DIR = customDir;
|
|
1184
|
+
|
|
1185
|
+
const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
|
|
1186
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
1187
|
+
fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'fallback prompt');
|
|
1188
|
+
|
|
1189
|
+
const result = loadAgentPrompt('oracle');
|
|
1190
|
+
expect(result.prompt).toBe('fallback prompt');
|
|
1191
|
+
|
|
1192
|
+
fs.rmSync(customDir, { recursive: true, force: true });
|
|
1193
|
+
});
|
|
1194
|
+
});
|