Files
multica/server/pkg/agent/opencode_test.go
Multica Eve e2720f7d33 feat: add opencode thinking variants
Adds OpenCode model variant discovery for thinking controls, passes saved thinking_level through opencode run --variant, and hardens verbose model parsing with fallback coverage.
2026-06-02 13:15:14 +08:00

1195 lines
34 KiB
Go

package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestNewReturnsOpencodeBackend(t *testing.T) {
t.Parallel()
b, err := New("opencode", Config{ExecutablePath: "/nonexistent/opencode"})
if err != nil {
t.Fatalf("New(opencode) error: %v", err)
}
if _, ok := b.(*opencodeBackend); !ok {
t.Fatalf("expected *opencodeBackend, got %T", b)
}
}
// ── Text event tests ──
func TestOpencodeHandleTextEvent(t *testing.T) {
t.Parallel()
b := &opencodeBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := opencodeEvent{
Type: "text",
SessionID: "ses_abc",
Part: opencodeEventPart{
Type: "text",
Text: "Hello from opencode",
},
}
b.handleTextEvent(event, ch, &output)
if output.String() != "Hello from opencode" {
t.Errorf("output: got %q, want %q", output.String(), "Hello from opencode")
}
msg := <-ch
if msg.Type != MessageText {
t.Errorf("type: got %v, want MessageText", msg.Type)
}
if msg.Content != "Hello from opencode" {
t.Errorf("content: got %q, want %q", msg.Content, "Hello from opencode")
}
}
func TestOpencodeHandleTextEventEmpty(t *testing.T) {
t.Parallel()
b := &opencodeBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := opencodeEvent{
Type: "text",
Part: opencodeEventPart{Type: "text", Text: ""},
}
b.handleTextEvent(event, ch, &output)
if output.String() != "" {
t.Errorf("expected empty output, got %q", output.String())
}
if len(ch) != 0 {
t.Errorf("expected no messages, got %d", len(ch))
}
}
// ── Tool use event tests (real opencode schema) ──
func TestOpencodeHandleToolUseEventCompleted(t *testing.T) {
t.Parallel()
b := &opencodeBackend{}
ch := make(chan Message, 10)
// Real opencode tool_use event: single event with state containing both
// call parameters and result.
event := opencodeEvent{
Type: "tool_use",
Part: opencodeEventPart{
Tool: "bash",
CallID: "call_BHA1",
State: &opencodeToolState{
Status: "completed",
Input: json.RawMessage(`{"command":"pwd","description":"Prints current working directory path"}`),
Output: "/tmp/multica\n",
},
},
}
b.handleToolUseEvent(event, ch)
// Should emit both a tool-use and a tool-result message.
if len(ch) != 2 {
t.Fatalf("expected 2 messages, got %d", len(ch))
}
// First: tool-use
msg := <-ch
if msg.Type != MessageToolUse {
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
}
if msg.Tool != "bash" {
t.Errorf("tool: got %q, want %q", msg.Tool, "bash")
}
if msg.CallID != "call_BHA1" {
t.Errorf("callID: got %q, want %q", msg.CallID, "call_BHA1")
}
if cmd, ok := msg.Input["command"].(string); !ok || cmd != "pwd" {
t.Errorf("input.command: got %v", msg.Input["command"])
}
// Second: tool-result
msg = <-ch
if msg.Type != MessageToolResult {
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
}
if msg.CallID != "call_BHA1" {
t.Errorf("callID: got %q, want %q", msg.CallID, "call_BHA1")
}
if msg.Output != "/tmp/multica\n" {
t.Errorf("output: got %q", msg.Output)
}
}
func TestOpencodeHandleToolUseEventPending(t *testing.T) {
t.Parallel()
b := &opencodeBackend{}
ch := make(chan Message, 10)
// Tool use with pending status — only emit tool-use, no result.
event := opencodeEvent{
Type: "tool_use",
Part: opencodeEventPart{
Tool: "read",
CallID: "call_ABC",
State: &opencodeToolState{
Status: "pending",
Input: json.RawMessage(`{"filePath":"/tmp/test.go"}`),
},
},
}
b.handleToolUseEvent(event, ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message for pending tool, got %d", len(ch))
}
msg := <-ch
if msg.Type != MessageToolUse {
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
}
if msg.Tool != "read" {
t.Errorf("tool: got %q, want %q", msg.Tool, "read")
}
}
func TestOpencodeHandleToolUseEventStructuredOutput(t *testing.T) {
t.Parallel()
b := &opencodeBackend{}
ch := make(chan Message, 10)
// Tool with structured (non-string) output.
event := opencodeEvent{
Type: "tool_use",
Part: opencodeEventPart{
Tool: "glob",
CallID: "call_XYZ",
State: &opencodeToolState{
Status: "completed",
Input: json.RawMessage(`{"pattern":"*.go"}`),
Output: map[string]any{"files": []any{"main.go", "main_test.go"}},
},
},
}
b.handleToolUseEvent(event, ch)
// tool-use + tool-result
if len(ch) != 2 {
t.Fatalf("expected 2 messages, got %d", len(ch))
}
<-ch // skip tool-use
msg := <-ch
if msg.Type != MessageToolResult {
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
}
if !strings.Contains(msg.Output, "main.go") {
t.Errorf("output should contain 'main.go', got %q", msg.Output)
}
}
func TestOpencodeHandleToolUseEventNilState(t *testing.T) {
t.Parallel()
b := &opencodeBackend{}
ch := make(chan Message, 10)
// Tool use with no state at all — should emit tool-use with no crash.
event := opencodeEvent{
Type: "tool_use",
Part: opencodeEventPart{
Tool: "write",
CallID: "call_NUL",
},
}
b.handleToolUseEvent(event, ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message, got %d", len(ch))
}
msg := <-ch
if msg.Type != MessageToolUse {
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
}
}
// ── Error event tests ──
func TestOpencodeHandleErrorEvent(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := opencodeEvent{
Type: "error",
SessionID: "ses_abc",
Error: &opencodeError{
Name: "UnknownError",
Data: &opencodeErrData{
Message: "Model not found: definitely/not-a-model.",
},
},
}
b.handleErrorEvent(event, ch, &status, &errMsg)
if status != "failed" {
t.Errorf("status: got %q, want %q", status, "failed")
}
if errMsg != "Model not found: definitely/not-a-model." {
t.Errorf("error: got %q", errMsg)
}
msg := <-ch
if msg.Type != MessageError {
t.Errorf("type: got %v, want MessageError", msg.Type)
}
if msg.Content != "Model not found: definitely/not-a-model." {
t.Errorf("content: got %q", msg.Content)
}
}
func TestOpencodeHandleErrorEventNameOnly(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
// Error with name but no data.message — should fall back to name.
event := opencodeEvent{
Type: "error",
Error: &opencodeError{
Name: "RateLimitError",
},
}
b.handleErrorEvent(event, ch, &status, &errMsg)
if errMsg != "RateLimitError" {
t.Errorf("error: got %q, want %q", errMsg, "RateLimitError")
}
}
func TestOpencodeHandleErrorEventNilError(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := opencodeEvent{Type: "error"}
b.handleErrorEvent(event, ch, &status, &errMsg)
if errMsg != "unknown opencode error" {
t.Errorf("error: got %q, want %q", errMsg, "unknown opencode error")
}
}
// ── JSON parsing tests with real fixtures ──
func TestOpencodeEventParsingTextFixture(t *testing.T) {
t.Parallel()
line := `{"type":"text","timestamp":1775116675833,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","type":"text","text":"pong","time":{"start":1775116675833,"end":1775116675833}}}`
var event opencodeEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if event.Type != "text" {
t.Errorf("type: got %q, want %q", event.Type, "text")
}
if event.SessionID != "ses_abc" {
t.Errorf("sessionID: got %q, want %q", event.SessionID, "ses_abc")
}
if event.Part.Text != "pong" {
t.Errorf("part.text: got %q, want %q", event.Part.Text, "pong")
}
}
func TestOpencodeEventParsingToolUseFixture(t *testing.T) {
t.Parallel()
// Real `tool_use` JSON from live `opencode run --format json` output.
line := `{"type":"tool_use","timestamp":1775117187163,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","type":"tool","tool":"bash","callID":"call_BHA1","state":{"status":"completed","input":{"command":"pwd","description":"Prints current working directory path"},"output":"/tmp/multica\n","metadata":{"exit":0},"time":{"start":1775117187092,"end":1775117187162}}}}`
var event opencodeEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if event.Type != "tool_use" {
t.Errorf("type: got %q, want %q", event.Type, "tool_use")
}
if event.Part.Tool != "bash" {
t.Errorf("part.tool: got %q, want %q", event.Part.Tool, "bash")
}
if event.Part.CallID != "call_BHA1" {
t.Errorf("part.callID: got %q, want %q", event.Part.CallID, "call_BHA1")
}
if event.Part.State == nil {
t.Fatal("part.state is nil")
}
if event.Part.State.Status != "completed" {
t.Errorf("state.status: got %q, want %q", event.Part.State.Status, "completed")
}
// Parse state.input
var input map[string]any
if err := json.Unmarshal(event.Part.State.Input, &input); err != nil {
t.Fatalf("unmarshal state.input: %v", err)
}
if input["command"] != "pwd" {
t.Errorf("state.input.command: got %v, want %q", input["command"], "pwd")
}
// state.output should be a string
if output, ok := event.Part.State.Output.(string); !ok || output != "/tmp/multica\n" {
t.Errorf("state.output: got %v (%T)", event.Part.State.Output, event.Part.State.Output)
}
}
func TestOpencodeEventParsingErrorFixture(t *testing.T) {
t.Parallel()
line := `{"type":"error","timestamp":1775117233612,"sessionID":"ses_abc","error":{"name":"UnknownError","data":{"message":"Model not found: definitely/not-a-model."}}}`
var event opencodeEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if event.Type != "error" {
t.Errorf("type: got %q, want %q", event.Type, "error")
}
if event.Error == nil {
t.Fatal("error field is nil")
}
if event.Error.Name != "UnknownError" {
t.Errorf("error.name: got %q", event.Error.Name)
}
if got := event.Error.Message(); got != "Model not found: definitely/not-a-model." {
t.Errorf("error.Message(): got %q", got)
}
}
func TestOpencodeEventParsingStepStartFixture(t *testing.T) {
t.Parallel()
line := `{"type":"step_start","timestamp":1775116675819,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","snapshot":"abc123","type":"step-start"}}`
var event opencodeEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if event.Type != "step_start" {
t.Errorf("type: got %q, want %q", event.Type, "step_start")
}
if event.SessionID != "ses_abc" {
t.Errorf("sessionID: got %q", event.SessionID)
}
}
func TestOpencodeStepFinishParsing(t *testing.T) {
t.Parallel()
line := `{"type":"step_finish","timestamp":1775116676180,"sessionID":"ses_abc","part":{"id":"prt_789","reason":"stop","snapshot":"abc123","messageID":"msg_456","sessionID":"ses_abc","type":"step-finish","tokens":{"total":14674,"input":14585,"output":89,"reasoning":82,"cache":{"write":0,"read":0}},"cost":0}}`
var event opencodeEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if event.Type != "step_finish" {
t.Errorf("type: got %q, want %q", event.Type, "step_finish")
}
if event.SessionID != "ses_abc" {
t.Errorf("sessionID: got %q", event.SessionID)
}
}
// ── extractToolOutput tests ──
func TestExtractToolOutputString(t *testing.T) {
t.Parallel()
if got := extractToolOutput("hello\n"); got != "hello\n" {
t.Errorf("got %q, want %q", got, "hello\n")
}
}
func TestExtractToolOutputNil(t *testing.T) {
t.Parallel()
if got := extractToolOutput(nil); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestExtractToolOutputStructured(t *testing.T) {
t.Parallel()
obj := map[string]any{"key": "value"}
got := extractToolOutput(obj)
if !strings.Contains(got, `"key"`) || !strings.Contains(got, `"value"`) {
t.Errorf("got %q, expected JSON containing key/value", got)
}
}
// ── opencodeError.Message() tests ──
func TestOpencodeErrorMessage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err *opencodeError
want string
}{
{
name: "data message",
err: &opencodeError{Name: "Err", Data: &opencodeErrData{Message: "details"}},
want: "details",
},
{
name: "name only",
err: &opencodeError{Name: "RateLimitError"},
want: "RateLimitError",
},
{
name: "empty",
err: &opencodeError{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Message(); got != tt.want {
t.Errorf("Message() = %q, want %q", got, tt.want)
}
})
}
}
// ── Integration-level tests: processEvents ──
//
// These feed multiple JSON lines through processEvents and verify the
// accumulated result (status, output, sessionID, error) and emitted messages.
func TestOpencodeProcessEventsHappyPath(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// Simulate a successful run: step_start → text → tool_use → text → step_finish
lines := strings.Join([]string{
`{"type":"step_start","timestamp":1000,"sessionID":"ses_happy","part":{"type":"step-start"}}`,
`{"type":"text","timestamp":1001,"sessionID":"ses_happy","part":{"type":"text","text":"Analyzing the issue..."}}`,
`{"type":"tool_use","timestamp":1002,"sessionID":"ses_happy","part":{"tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"ls"},"output":"file1.go\nfile2.go\n"}}}`,
`{"type":"text","timestamp":1003,"sessionID":"ses_happy","part":{"type":"text","text":" Done."}}`,
`{"type":"step_finish","timestamp":1004,"sessionID":"ses_happy","part":{"type":"step-finish"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
// Verify result.
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.sessionID != "ses_happy" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_happy")
}
if result.output != "Analyzing the issue... Done." {
t.Errorf("output: got %q, want %q", result.output, "Analyzing the issue... Done.")
}
if result.errMsg != "" {
t.Errorf("errMsg: got %q, want empty", result.errMsg)
}
// Drain and verify messages.
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
// Expected: status(running), text, tool-use, tool-result, text, = 5 messages
if len(msgs) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
}
if msgs[0].Type != MessageStatus || msgs[0].Status != "running" {
t.Errorf("msg[0]: got %+v, want status=running", msgs[0])
}
if msgs[1].Type != MessageText || msgs[1].Content != "Analyzing the issue..." {
t.Errorf("msg[1]: got %+v", msgs[1])
}
if msgs[2].Type != MessageToolUse || msgs[2].Tool != "bash" {
t.Errorf("msg[2]: got %+v, want tool-use(bash)", msgs[2])
}
if msgs[3].Type != MessageToolResult || msgs[3].Output != "file1.go\nfile2.go\n" {
t.Errorf("msg[3]: got %+v, want tool-result", msgs[3])
}
if msgs[4].Type != MessageText || msgs[4].Content != " Done." {
t.Errorf("msg[4]: got %+v", msgs[4])
}
}
func TestOpencodeProcessEventsErrorCausesFailedStatus(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// Simulate: step_start → error (model not found) → step_finish.
// OpenCode exits RC=0 on error events, so the error event is the only
// signal that something went wrong.
lines := strings.Join([]string{
`{"type":"step_start","timestamp":1000,"sessionID":"ses_err","part":{"type":"step-start"}}`,
`{"type":"error","timestamp":1001,"sessionID":"ses_err","error":{"name":"UnknownError","data":{"message":"Model not found: bad/model"}}}`,
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_err","part":{"type":"step-finish"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if result.errMsg != "Model not found: bad/model" {
t.Errorf("errMsg: got %q", result.errMsg)
}
if result.sessionID != "ses_err" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_err")
}
close(ch)
var errorMsgs int
for m := range ch {
if m.Type == MessageError {
errorMsgs++
}
}
if errorMsgs != 1 {
t.Errorf("expected 1 error message, got %d", errorMsgs)
}
}
func TestOpencodeProcessEventsSessionIDExtracted(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// Session ID should be captured from the last event that has one.
lines := strings.Join([]string{
`{"type":"step_start","timestamp":1000,"sessionID":"ses_first","part":{"type":"step-start"}}`,
`{"type":"text","timestamp":1001,"sessionID":"ses_updated","part":{"type":"text","text":"hi"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.sessionID != "ses_updated" {
t.Errorf("sessionID: got %q, want %q (should use last seen)", result.sessionID, "ses_updated")
}
close(ch)
}
func TestOpencodeProcessEventsScannerError(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// Use an ioErrReader that returns valid data then an I/O error, which
// triggers scanner.Err() and should set status to "failed".
result := b.processEvents(&ioErrReader{
data: `{"type":"text","sessionID":"ses_scan","part":{"text":"before error"}}` + "\n",
}, ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if !strings.Contains(result.errMsg, "stdout read error") {
t.Errorf("errMsg: got %q, want it to contain 'stdout read error'", result.errMsg)
}
// The text event before the error should still be captured.
if result.output != "before error" {
t.Errorf("output: got %q, want %q", result.output, "before error")
}
close(ch)
}
// ioErrReader delivers data on the first Read, then returns an error on the second.
type ioErrReader struct {
data string
read bool
}
func (r *ioErrReader) Read(p []byte) (int, error) {
if !r.read {
r.read = true
n := copy(p, r.data)
return n, nil
}
return 0, fmt.Errorf("simulated I/O error")
}
func TestOpencodeProcessEventsEmptyLines(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// Empty lines and invalid JSON should be skipped without error.
lines := strings.Join([]string{
"",
" ",
"not json at all",
`{"type":"text","sessionID":"ses_ok","part":{"text":"valid"}}`,
"",
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.output != "valid" {
t.Errorf("output: got %q, want %q", result.output, "valid")
}
if result.sessionID != "ses_ok" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_ok")
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 1 || msgs[0].Type != MessageText {
t.Errorf("expected 1 text message, got %d: %+v", len(msgs), msgs)
}
}
func TestOpencodeProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// Error event followed by more text — status should remain "failed".
lines := strings.Join([]string{
`{"type":"error","sessionID":"ses_x","error":{"name":"RateLimitError"}}`,
`{"type":"text","sessionID":"ses_x","part":{"text":"recovered?"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q (error should stick)", result.status, "failed")
}
if result.errMsg != "RateLimitError" {
t.Errorf("errMsg: got %q, want %q", result.errMsg, "RateLimitError")
}
close(ch)
}
// ── Windows native-binary resolution tests ──
// fakeStat returns a statFn that reports any path in `present` as existing
// and every other path as not-found. The returned os.FileInfo is a stub
// because resolveOpenCodeNativeFromShim only inspects the error.
func fakeStat(present ...string) func(string) (os.FileInfo, error) {
set := make(map[string]struct{}, len(present))
for _, p := range present {
set[p] = struct{}{}
}
return func(path string) (os.FileInfo, error) {
if _, ok := set[path]; ok {
return nil, nil
}
return nil, errors.New("not found")
}
}
func TestResolveOpenCodeNativeFromShimResolvesNpmShim(t *testing.T) {
t.Parallel()
// Reporter's exact layout from multica#1717.
shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd")
native := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin", "opencode.exe")
got := resolveOpenCodeNativeFromShim(shim, fakeStat(native))
if got != native {
t.Errorf("got %q, want %q", got, native)
}
}
func TestResolveOpenCodeNativeFromShimReturnsEmptyWhenNativeMissing(t *testing.T) {
t.Parallel()
// Shim ends in .cmd but the bundled native binary isn't present (e.g.
// platform package didn't install or layout changed). Caller must keep
// the original shim path so PATH lookup still wins.
shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd")
got := resolveOpenCodeNativeFromShim(shim, fakeStat())
if got != "" {
t.Errorf("got %q, want empty (missing native binary)", got)
}
}
func TestResolveOpenCodeNativeFromShimSkipsNonCmdPath(t *testing.T) {
t.Parallel()
// On macOS/Linux the path returned by exec.LookPath is the native
// binary itself, with no .cmd extension. Helper should signal "no
// rewrite needed" by returning empty.
cases := []string{
"/usr/local/bin/opencode",
"C:\\nvm4w\\nodejs\\opencode.exe",
"",
}
for _, p := range cases {
if got := resolveOpenCodeNativeFromShim(p, fakeStat("anything")); got != "" {
t.Errorf("path %q: got %q, want empty", p, got)
}
}
}
func TestResolveOpenCodeNativeFromShimAcceptsUppercaseExtension(t *testing.T) {
t.Parallel()
// Windows is case-insensitive on filesystem extensions. PATHEXT tokens
// are commonly uppercase, and exec.LookPath can return either case.
shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.CMD")
native := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin", "opencode.exe")
got := resolveOpenCodeNativeFromShim(shim, fakeStat(native))
if got != native {
t.Errorf("got %q, want %q", got, native)
}
}
func TestResolveOpenCodeNativeFromShimFallsBackToBaseline(t *testing.T) {
t.Parallel()
// Older CPUs without AVX2 get `opencode-windows-x64-baseline` instead of
// the default x64 build. Resolver should fall through and find it when
// the primary x64 package isn't installed.
shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd")
baseline := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe")
got := resolveOpenCodeNativeFromShim(shim, fakeStat(baseline))
if got != baseline {
t.Errorf("got %q, want %q", got, baseline)
}
}
func TestOpencodeWindowsPackageCandidatesArm64(t *testing.T) {
t.Parallel()
// ARM64 hosts (Surface, Copilot+ PC) should try arm64 first so the
// resolver doesn't accidentally pick up a leftover x64 install when
// the matching arm64 package is present.
got := opencodeWindowsPackageCandidates("arm64")
want := []string{"opencode-windows-arm64", "opencode-windows-x64", "opencode-windows-x64-baseline"}
if !equalStringSlice(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestOpencodeWindowsPackageCandidatesAmd64(t *testing.T) {
t.Parallel()
// amd64 (and any non-arm64) hosts try x64 → baseline → arm64. The arm64
// fallback at the end covers the unusual case where only the arm64
// package is installed; resolution still succeeds.
got := opencodeWindowsPackageCandidates("amd64")
want := []string{"opencode-windows-x64", "opencode-windows-x64-baseline", "opencode-windows-arm64"}
if !equalStringSlice(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
// fakeOpencodeScript returns a POSIX-sh script that impersonates `opencode`
// for argv / env capture. It writes the argv (one per line) to
// $OPENCODE_ARGS_FILE, the resolved PWD to $OPENCODE_PWD_FILE, and the
// permission config to $OPENCODE_PERMISSION_FILE. It emits a minimal completed
// step on stdout so the daemon's event loop terminates, then exits.
func fakeOpencodeScript() string {
return `#!/bin/sh
if [ -n "$OPENCODE_ARGS_FILE" ]; then
for arg in "$@"; do
printf '%s\n' "$arg" >> "$OPENCODE_ARGS_FILE"
done
fi
if [ -n "$OPENCODE_PWD_FILE" ]; then
printf '%s\n' "$PWD" > "$OPENCODE_PWD_FILE"
fi
if [ -n "$OPENCODE_PERMISSION_FILE" ]; then
printf '%s\n' "$OPENCODE_PERMISSION" > "$OPENCODE_PERMISSION_FILE"
fi
if [ -f "$PWD/opencode.json" ] && grep -Eq '"question"[[:space:]]*:[[:space:]]*"allow"' "$PWD/opencode.json"; then
if [ "$OPENCODE_PERMISSION" = '{"*":"allow","question":"deny"}' ]; then
printf '{"type":"error","timestamp":1,"sessionID":"ses_fake","error":{"name":"PermissionBypass","data":{"message":"question permission bypassed by env wildcard order"}}}\n'
exit 0
fi
fi
printf '{"type":"step_start","timestamp":1,"sessionID":"ses_fake","part":{"type":"step-start"}}\n'
printf '{"type":"text","timestamp":2,"sessionID":"ses_fake","part":{"type":"text","text":"ok"}}\n'
printf '{"type":"step_finish","timestamp":3,"sessionID":"ses_fake","part":{"type":"step-finish"}}\n'
`
}
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
// TestOpencodeBackendAnchorsDirAndPWD pins the discovery-root fix from
// MUL-2416: OpenCode resolves its AGENTS.md walk-up and .opencode/skills
// project config scan from `--dir` and PWD. cmd.Dir alone is not enough
// because OpenCode reads PWD (inherited from the daemon) before falling
// back to process.cwd(). Without this anchor, skills written into the
// task workdir are silently invisible and the agent runs against the
// daemon's shell working directory.
func TestOpencodeBackendAnchorsDirAndPWD(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
argsFile := filepath.Join(tempDir, "argv.txt")
pwdFile := filepath.Join(tempDir, "pwd.txt")
fakePath := filepath.Join(tempDir, "opencode")
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
workDir := t.TempDir()
backend, err := New("opencode", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{
"OPENCODE_ARGS_FILE": argsFile,
"OPENCODE_PWD_FILE": pwdFile,
},
})
if err != nil {
t.Fatalf("new opencode backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Cwd: workDir,
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
<-session.Result
// argv should include `--dir <workDir>` immediately after the `run` /
// `--format json` prefix and nowhere else.
raw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read args file: %v", err)
}
args := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(args) < 2 || args[0] != "run" {
t.Fatalf("expected first arg to be 'run', got %q", args)
}
dirIdx := -1
for i, a := range args {
if a == "--dir" {
dirIdx = i
break
}
}
if dirIdx == -1 {
t.Fatalf("expected --dir flag in argv, got %q", args)
}
if dirIdx+1 >= len(args) || args[dirIdx+1] != workDir {
t.Fatalf("expected --dir %q, got args=%q", workDir, args)
}
// PWD inside the child process must resolve to the task workdir,
// otherwise OpenCode's project discovery walk starts in the wrong
// directory and silently misses .opencode/skills + AGENTS.md.
gotPWD, err := os.ReadFile(pwdFile)
if err != nil {
t.Fatalf("read PWD file: %v", err)
}
if got := strings.TrimSpace(string(gotPWD)); got != workDir {
t.Errorf("child PWD = %q, want %q", got, workDir)
}
}
func TestOpencodeBackendInjectsThinkingVariant(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
argsFile := filepath.Join(tempDir, "argv.txt")
fakePath := filepath.Join(tempDir, "opencode")
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
backend, err := New("opencode", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{
"OPENCODE_ARGS_FILE": argsFile,
},
})
if err != nil {
t.Fatalf("new opencode backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Model: "opencode/deepseek-v4",
ThinkingLevel: "max",
CustomArgs: []string{"--variant", "low", "--keep-me"},
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
<-session.Result
raw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read args file: %v", err)
}
args := splitNonEmptyLines(string(raw))
if !containsAdjacent(args, "--model", "opencode/deepseek-v4") {
t.Fatalf("expected --model opencode/deepseek-v4 in args: %v", args)
}
if !containsAdjacent(args, "--variant", "max") {
t.Fatalf("expected daemon-injected --variant max in args: %v", args)
}
if argIndexOf(args, "--model") > argIndexOf(args, "--variant") {
t.Errorf("expected --variant after --model for readability: %v", args)
}
count := 0
for _, arg := range args {
if arg == "--variant" {
count++
}
}
if count != 1 {
t.Fatalf("expected exactly one --variant, got %d: %v", count, args)
}
if argIndexOf(args, "low") >= 0 {
t.Errorf("filtered user --variant value still appears: %v", args)
}
if argIndexOf(args, "--keep-me") < 0 {
t.Errorf("non-blocked custom arg was dropped: %v", args)
}
}
func TestOpencodeBackendDoesNotUsePermissionEnvOverride(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
permissionFile := filepath.Join(tempDir, "permission.json")
fakePath := filepath.Join(tempDir, "opencode")
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
backend, err := New("opencode", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{
"OPENCODE_PERMISSION_FILE": permissionFile,
},
})
if err != nil {
t.Fatalf("new opencode backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
<-session.Result
raw, err := os.ReadFile(permissionFile)
if err != nil {
t.Fatalf("read permission file: %v", err)
}
if got := strings.TrimSpace(string(raw)); got != "" {
t.Fatalf("OPENCODE_PERMISSION = %q, want empty env override", got)
}
}
func TestOpencodeBackendQuestionDenySurvivesUserConfig(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
argsFile := filepath.Join(tempDir, "argv.txt")
fakePath := filepath.Join(tempDir, "opencode")
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
workDir := t.TempDir()
if err := os.WriteFile(
filepath.Join(workDir, "opencode.json"),
[]byte(`{"permission":{"question":"allow"}}`),
0o644,
); err != nil {
t.Fatalf("write opencode config: %v", err)
}
backend, err := New("opencode", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{
"OPENCODE_ARGS_FILE": argsFile,
},
})
if err != nil {
t.Fatalf("new opencode backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Cwd: workDir,
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
result := <-session.Result
if result.Status != "completed" {
t.Fatalf("result status = %q, error = %q; want completed", result.Status, result.Error)
}
raw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read args file: %v", err)
}
args := strings.Split(strings.TrimSpace(string(raw)), "\n")
if !containsString(args, "--dangerously-skip-permissions") {
t.Fatalf("expected daemon-mode argv to include --dangerously-skip-permissions, got %q", args)
}
}
// TestOpencodeBackendBlocksDirOverride ensures user-supplied custom args
// cannot replace the daemon-managed `--dir` anchor. Letting custom args
// override it would re-introduce the MUL-2416 regression.
func TestOpencodeBackendBlocksDirOverride(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
argsFile := filepath.Join(tempDir, "argv.txt")
fakePath := filepath.Join(tempDir, "opencode")
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
workDir := t.TempDir()
bogusDir := t.TempDir()
backend, err := New("opencode", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{
"OPENCODE_ARGS_FILE": argsFile,
},
})
if err != nil {
t.Fatalf("new opencode backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Cwd: workDir,
Timeout: 5 * time.Second,
CustomArgs: []string{"--dir", bogusDir},
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
<-session.Result
raw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read args file: %v", err)
}
args := strings.Split(strings.TrimSpace(string(raw)), "\n")
for i, a := range args {
if a == "--dir" {
if i+1 >= len(args) || args[i+1] != workDir {
t.Errorf("--dir was overridden by custom args: got %q", args)
}
}
if a == bogusDir {
t.Errorf("custom --dir value %q leaked into argv: %q", bogusDir, args)
}
}
}
func equalStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}