Skip to content

Commit 7fbebe6

Browse files
test: cover OpenCode adapter roles, MCP translation, native skills, and doctor
1 parent fb4d9e9 commit 7fbebe6

1 file changed

Lines changed: 389 additions & 0 deletions

File tree

cmd/dotagents/opencode_test.go

Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"gopkg.in/yaml.v3"
11+
)
12+
13+
func TestOpenCodeHarnessCapabilitiesAndDefaults(t *testing.T) {
14+
h := harnessFor(agentOpenCode)
15+
if h == nil {
16+
t.Fatal("OpenCode harness is not registered")
17+
}
18+
if h.Skills != SkillsSymlink {
19+
t.Fatalf("OpenCode skills capability = %v, want symlink", h.Skills)
20+
}
21+
if h.SkillsNativeRoot == nil {
22+
t.Fatal("OpenCode must declare a native skill root predicate")
23+
}
24+
if h.MCP == nil {
25+
t.Fatal("OpenCode does not expose MCP support")
26+
}
27+
if h.Roles == nil || h.Roles.Extension != ".md" {
28+
t.Fatalf("OpenCode roles capability = %#v, want Markdown roles", h.Roles)
29+
}
30+
if h.Hooks != nil {
31+
t.Fatal("OpenCode must not declare hook support (JS plugin surface only)")
32+
}
33+
34+
var found *agentConfig
35+
for _, agent := range defaultAgentConfigs() {
36+
if agent.Name == agentOpenCode {
37+
a := agent
38+
found = &a
39+
}
40+
}
41+
if found == nil {
42+
t.Fatal("opencode missing from defaultAgentConfigs")
43+
}
44+
if found.SkillRoot != "~/.config/opencode/skills" || found.AgentRoot != "~/.config/opencode/agents" || found.Detect != "opencode" {
45+
t.Fatalf("opencode default config = %#v", *found)
46+
}
47+
}
48+
49+
func TestOpenCodeDetectionFromPATH(t *testing.T) {
50+
fakePath(t, "opencode")
51+
detected, err := detectDefaultAgents("")
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
var names []string
56+
for _, agent := range detected {
57+
names = append(names, agent.Name)
58+
}
59+
if strings.Join(names, ",") != agentOpenCode {
60+
t.Fatalf("detected agents = %v, want opencode", names)
61+
}
62+
}
63+
64+
func TestOpenCodeRoleRenderDefaultMode(t *testing.T) {
65+
role := agentRole{
66+
Name: "researcher",
67+
Description: "Find reliable evidence",
68+
Model: "opus",
69+
Effort: "high",
70+
Instructions: "Compare the sources.",
71+
Source: "agents/researcher.md",
72+
}
73+
content := renderOpenCodeAgentRole(role)
74+
75+
if strings.Contains(content, "name:") {
76+
t.Fatalf("opencode role should not emit a name field (filename is the name):\n%s", content)
77+
}
78+
if !strings.Contains(content, "description: \"Find reliable evidence\"") {
79+
t.Fatalf("opencode role missing description:\n%s", content)
80+
}
81+
if !strings.Contains(content, "mode: subagent") {
82+
t.Fatalf("opencode role missing default mode:\n%s", content)
83+
}
84+
if strings.Contains(content, "model:") || strings.Contains(content, "temperature:") {
85+
t.Fatalf("opencode role should omit model/temperature without an override:\n%s", content)
86+
}
87+
if !strings.Contains(content, generatedAgentMarker) {
88+
t.Fatalf("opencode role missing managed marker:\n%s", content)
89+
}
90+
if !strings.Contains(content, "Compare the sources.") {
91+
t.Fatalf("opencode role dropped instructions:\n%s", content)
92+
}
93+
}
94+
95+
func TestOpenCodeRoleRenderHonorsOverride(t *testing.T) {
96+
role := agentRole{
97+
Name: "reviewer",
98+
Description: "Reviews code",
99+
Instructions: "Review carefully.",
100+
Opencode: opencodeRoleOptions{Model: "anthropic/claude-sonnet-4", Temperature: "0.1", Mode: "primary"},
101+
}
102+
content := renderOpenCodeAgentRole(role)
103+
if !strings.Contains(content, "mode: primary") {
104+
t.Fatalf("opencode override mode not applied:\n%s", content)
105+
}
106+
if !strings.Contains(content, "model: \"anthropic/claude-sonnet-4\"") {
107+
t.Fatalf("opencode override model not applied:\n%s", content)
108+
}
109+
if !strings.Contains(content, "temperature: 0.1") {
110+
t.Fatalf("opencode override temperature not applied:\n%s", content)
111+
}
112+
}
113+
114+
func TestOpenCodeRoleRenderFromCanonicalMarkdown(t *testing.T) {
115+
repoRoot := t.TempDir()
116+
writeSyncTestFile(t, filepath.Join(repoRoot, "agents", "planner.md"), []byte(`---
117+
name: planner
118+
description: Plans work
119+
opencode:
120+
mode: all
121+
temperature: 0.2
122+
---
123+
124+
Plan the work.
125+
`))
126+
agent := agentConfig{Name: agentOpenCode, AgentRoot: filepath.Join(t.TempDir(), "agents")}
127+
expected, err := expectedAgentRoles(repoRoot, agent)
128+
if err != nil {
129+
t.Fatal(err)
130+
}
131+
rendered, ok := expected["planner"]
132+
if !ok {
133+
t.Fatalf("planner role not rendered: %#v", expected)
134+
}
135+
if !strings.Contains(rendered.Content, "mode: all") || !strings.Contains(rendered.Content, "temperature: 0.2") {
136+
t.Fatalf("canonical opencode override not honored:\n%s", rendered.Content)
137+
}
138+
}
139+
140+
func TestOpenCodeMCPFreshFilePatchAndInspect(t *testing.T) {
141+
t.Setenv("XDG_CONFIG_HOME", "")
142+
home := t.TempDir()
143+
server := testMCPServer()
144+
server.Agents = []string{agentOpenCode}
145+
146+
if err := patchMCPServer(agentOpenCode, server, home); err != nil {
147+
t.Fatal(err)
148+
}
149+
configPath := filepath.Join(home, ".config", "opencode", "opencode.json")
150+
data, err := os.ReadFile(configPath)
151+
if err != nil {
152+
t.Fatalf("opencode.json not written: %v", err)
153+
}
154+
var raw map[string]interface{}
155+
if err := json.Unmarshal(data, &raw); err != nil {
156+
t.Fatal(err)
157+
}
158+
mcp, _ := raw["mcp"].(map[string]interface{})
159+
entry, _ := mcp["linkedin"].(map[string]interface{})
160+
if entry["type"] != "local" || entry["enabled"] != true {
161+
t.Fatalf("opencode MCP entry shape = %#v", entry)
162+
}
163+
command, _ := toStringSlice(entry["command"])
164+
if !stringSlicesEqual(command, []string{"uvx", "linkedin-scraper-mcp==4.13.2"}) {
165+
t.Fatalf("opencode command array = %#v, want folded command+args", entry["command"])
166+
}
167+
envMap, _ := asMap(entry["environment"])
168+
if envMap["UV_HTTP_TIMEOUT"] != "300" {
169+
t.Fatalf("opencode environment = %#v", entry["environment"])
170+
}
171+
if _, ok := entry["env"]; ok {
172+
t.Fatalf("opencode must use environment, not env: %#v", entry)
173+
}
174+
175+
state, err := inspectMCPServer(agentOpenCode, server, home)
176+
if err != nil {
177+
t.Fatal(err)
178+
}
179+
if state != stateSynced {
180+
t.Fatalf("opencode MCP state after patch = %q, want synced", state)
181+
}
182+
}
183+
184+
func TestOpenCodeMCPPreservesUnmanagedKeysAndServers(t *testing.T) {
185+
t.Setenv("XDG_CONFIG_HOME", "")
186+
home := t.TempDir()
187+
configPath := filepath.Join(home, ".config", "opencode", "opencode.json")
188+
writeSyncTestFile(t, configPath, []byte(`{
189+
"$schema": "https://opencode.ai/config.json",
190+
"theme": "opencode",
191+
"mcp": {
192+
"existing-remote": {"type": "remote", "url": "https://example.test/mcp", "enabled": true},
193+
"existing-local": {"type": "local", "command": ["node", "server.js"], "enabled": true}
194+
}
195+
}`))
196+
197+
server := testMCPServer()
198+
server.Agents = []string{agentOpenCode}
199+
if err := patchMCPServer(agentOpenCode, server, home); err != nil {
200+
t.Fatal(err)
201+
}
202+
data, err := os.ReadFile(configPath)
203+
if err != nil {
204+
t.Fatal(err)
205+
}
206+
var raw map[string]interface{}
207+
if err := json.Unmarshal(data, &raw); err != nil {
208+
t.Fatal(err)
209+
}
210+
if raw["theme"] != "opencode" || raw["$schema"] != "https://opencode.ai/config.json" {
211+
t.Fatalf("unmanaged top-level keys not preserved: %#v", raw)
212+
}
213+
mcp, _ := raw["mcp"].(map[string]interface{})
214+
if _, ok := mcp["existing-remote"]; !ok {
215+
t.Fatalf("unmanaged remote server dropped: %#v", mcp)
216+
}
217+
if _, ok := mcp["existing-local"]; !ok {
218+
t.Fatalf("unmanaged local server dropped: %#v", mcp)
219+
}
220+
if _, ok := mcp["linkedin"]; !ok {
221+
t.Fatalf("managed server not added: %#v", mcp)
222+
}
223+
}
224+
225+
func TestOpenCodeMCPImportReverseTranslation(t *testing.T) {
226+
t.Setenv("XDG_CONFIG_HOME", "")
227+
home := t.TempDir()
228+
configPath := filepath.Join(home, ".config", "opencode", "opencode.json")
229+
writeSyncTestFile(t, configPath, []byte(`{
230+
"mcp": {
231+
"search": {"type": "local", "command": ["uvx", "search-mcp", "--fast"], "environment": {"TOKEN": "${TOKEN}"}, "enabled": true},
232+
"remote-only": {"type": "remote", "url": "https://example.test/mcp"}
233+
}
234+
}`))
235+
236+
agent := agentConfig{Name: agentOpenCode, Enabled: true}
237+
candidates, err := scanNativeMCP(agent, config{Version: 1}, home)
238+
if err != nil {
239+
t.Fatal(err)
240+
}
241+
if len(candidates) != 1 {
242+
t.Fatalf("import candidates = %#v, want only the local server", candidates)
243+
}
244+
got := candidates[0].Server
245+
if got.Name != "search" || got.Command != "uvx" || !stringSlicesEqual(got.Args, []string{"search-mcp", "--fast"}) {
246+
t.Fatalf("reverse translation = %#v", got)
247+
}
248+
if got.Env["TOKEN"] != "${TOKEN}" {
249+
t.Fatalf("environment not reverse-translated to env: %#v", got.Env)
250+
}
251+
}
252+
253+
func TestOpenCodeConfigPathHonorsXDG(t *testing.T) {
254+
xdg := t.TempDir()
255+
t.Setenv("XDG_CONFIG_HOME", xdg)
256+
home := t.TempDir()
257+
if got, want := openCodeConfigPath(home), filepath.Join(xdg, "opencode", "opencode.json"); got != want {
258+
t.Fatalf("opencode config path = %q, want %q", got, want)
259+
}
260+
t.Setenv("XDG_CONFIG_HOME", "")
261+
if got, want := openCodeConfigPath(home), filepath.Join(home, ".config", "opencode", "opencode.json"); got != want {
262+
t.Fatalf("opencode config path without XDG = %q, want %q", got, want)
263+
}
264+
}
265+
266+
func TestOpenCodeSkillsNativeReadDoesNotMirror(t *testing.T) {
267+
t.Setenv("XDG_CONFIG_HOME", "")
268+
home := t.TempDir()
269+
repoRoot := filepath.Join(home, ".agents")
270+
writeSyncTestFile(t, filepath.Join(repoRoot, "skills", "grilling", "SKILL.md"), []byte("---\nname: grilling\n---\n"))
271+
272+
agent := agentConfig{Name: agentOpenCode, Enabled: true, SkillRoot: filepath.Join(home, ".config", "opencode", "skills")}
273+
expected := map[string]string{"grilling": filepath.Join(repoRoot, "skills", "grilling")}
274+
cfg := config{Version: 1, Agents: []agentConfig{agent}}
275+
276+
report, err := inspectAgent(agent, expected, repoRoot, filepath.Join(repoRoot, "skills"), cfg, home)
277+
if err != nil {
278+
t.Fatal(err)
279+
}
280+
if len(report.Adds) != 0 || len(report.Missing) != 0 {
281+
t.Fatalf("native read must have no skill adds: %#v", report)
282+
}
283+
if !stringInSlice("grilling", report.Managed) {
284+
t.Fatalf("native read must report skill as managed: %#v", report.Managed)
285+
}
286+
287+
if err := applyAgentSync([]agentReport{report}, cfg, repoRoot, home); err != nil {
288+
t.Fatal(err)
289+
}
290+
if _, err := os.Stat(agent.SkillRoot); !os.IsNotExist(err) {
291+
t.Fatalf("native read must not create a skill mirror at %s (err=%v)", agent.SkillRoot, err)
292+
}
293+
}
294+
295+
func TestOpenCodeSkillsMirrorWhenConfigRootDiffers(t *testing.T) {
296+
t.Setenv("XDG_CONFIG_HOME", "")
297+
home := t.TempDir()
298+
repoRoot := filepath.Join(home, "custom-agents")
299+
writeSyncTestFile(t, filepath.Join(repoRoot, "skills", "grilling", "SKILL.md"), []byte("---\nname: grilling\n---\n"))
300+
301+
agent := agentConfig{Name: agentOpenCode, Enabled: true, SkillRoot: filepath.Join(home, ".config", "opencode", "skills")}
302+
expected := map[string]string{"grilling": filepath.Join(repoRoot, "skills", "grilling")}
303+
cfg := config{Version: 1, Agents: []agentConfig{agent}}
304+
305+
report, err := inspectAgent(agent, expected, repoRoot, filepath.Join(repoRoot, "skills"), cfg, home)
306+
if err != nil {
307+
t.Fatal(err)
308+
}
309+
if !stringInSlice("grilling", report.Adds) {
310+
t.Fatalf("custom root must mirror skills: %#v", report.Adds)
311+
}
312+
if err := applyAgentSync([]agentReport{report}, cfg, repoRoot, home); err != nil {
313+
t.Fatal(err)
314+
}
315+
linkPath := filepath.Join(agent.SkillRoot, "grilling")
316+
if !sameResolvedPath(linkPath, filepath.Join(repoRoot, "skills", "grilling")) {
317+
rawTarget, _ := os.Readlink(linkPath)
318+
t.Fatalf("custom-root skill link %s -> %q, want %s", linkPath, rawTarget, filepath.Join(repoRoot, "skills", "grilling"))
319+
}
320+
}
321+
322+
func TestOpenCodeDoctorWarnsOnDuplicateSkills(t *testing.T) {
323+
t.Setenv("XDG_CONFIG_HOME", "")
324+
home := t.TempDir()
325+
dir := fakePath(t, "opencode")
326+
_ = dir
327+
cfg := config{Version: 1, Agents: []agentConfig{{Name: agentOpenCode, Enabled: true, Detect: "opencode", SkillRoot: filepath.Join(home, ".config", "opencode", "skills")}}}
328+
329+
// no duplicates yet
330+
res := checkOpenCodeDuplicateSkills("", home, cfg)
331+
if res.status != checkStatusPass {
332+
t.Fatalf("expected pass with no duplicates, got %#v", res)
333+
}
334+
335+
writeSyncTestFile(t, filepath.Join(home, ".agents", "skills", "grilling", "SKILL.md"), []byte("---\nname: grilling\n---\n"))
336+
writeSyncTestFile(t, filepath.Join(home, ".config", "opencode", "skills", "grilling", "SKILL.md"), []byte("---\nname: grilling\n---\n"))
337+
338+
res = checkOpenCodeDuplicateSkills("", home, cfg)
339+
if res.status != checkStatusWarn || !strings.Contains(res.detail, "grilling") {
340+
t.Fatalf("expected duplicate warning, got %#v", res)
341+
}
342+
}
343+
344+
func TestOpenCodeSetupImportScansAgentsAndMCP(t *testing.T) {
345+
t.Setenv("XDG_CONFIG_HOME", "")
346+
home := t.TempDir()
347+
root := t.TempDir()
348+
writeSyncTestFile(t, filepath.Join(home, ".config", "opencode", "agents", "helper.md"), []byte(`---
349+
description: Helps out
350+
mode: subagent
351+
---
352+
353+
Be helpful.
354+
`))
355+
writeSyncTestFile(t, filepath.Join(home, ".config", "opencode", "opencode.json"), []byte(`{
356+
"mcp": {
357+
"search": {"type": "local", "command": ["uvx", "search-mcp"], "enabled": true}
358+
}
359+
}`))
360+
361+
detected := []agentConfig{{Name: agentOpenCode, Enabled: true, SkillRoot: filepath.Join(home, ".config", "opencode", "skills"), AgentRoot: filepath.Join(home, ".config", "opencode", "agents")}}
362+
skills, roles, mcps, err := scanNativeImports(config{Version: 1, Agents: detected}, detected, root, home)
363+
if err != nil {
364+
t.Fatal(err)
365+
}
366+
if len(skills) != 0 {
367+
t.Fatalf("unexpected skill candidates: %#v", skills)
368+
}
369+
if len(roles) != 1 || roles[0].TargetName != "helper" {
370+
t.Fatalf("opencode role import candidates = %#v", roles)
371+
}
372+
if len(mcps) != 1 || mcps[0].Name != "search" {
373+
t.Fatalf("opencode MCP import candidates = %#v", mcps)
374+
}
375+
data, err := roles[0].Convert()
376+
if err != nil {
377+
t.Fatal(err)
378+
}
379+
var fm struct {
380+
Description string `yaml:"description"`
381+
}
382+
parts := strings.SplitN(string(data), "---\n", 3)
383+
if len(parts) == 3 {
384+
_ = yaml.Unmarshal([]byte(parts[1]), &fm)
385+
}
386+
if !strings.Contains(string(data), "Be helpful.") {
387+
t.Fatalf("converted opencode role dropped body:\n%s", data)
388+
}
389+
}

0 commit comments

Comments
 (0)