Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
0ac1b7aa43 feat(agent): add OpenCode provider support
Implement the OpenCode backend for the agent SDK, enabling Multica to
run tasks via the OpenCode CLI (`opencode run --format json`). Parses
the JSONL streaming protocol (text, tool_use, step_start, step_finish,
error events) and maps them to the unified Message types.

Changes:
- New `opencodeBackend` in `pkg/agent/opencode.go`
- Factory function updated to support "opencode" agent type
- Daemon config probes for `opencode` CLI on PATH
- Unit tests for all event handlers and JSON parsing
2026-04-03 13:55:18 +08:00
5 changed files with 577 additions and 4 deletions

View File

@@ -84,8 +84,15 @@ func LoadConfig(overrides Overrides) (Config, error) {
Model: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")),
}
}
opencodePath := envOrDefault("MULTICA_OPENCODE_PATH", "opencode")
if _, err := exec.LookPath(opencodePath); err == nil {
agents["opencode"] = AgentEntry{
Path: opencodePath,
Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCODE_MODEL")),
}
}
if len(agents) == 0 {
return Config{}, fmt.Errorf("no agent CLI found: install claude or codex and ensure it is on PATH")
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, or opencode and ensure it is on PATH")
}
// Host info

View File

@@ -1,6 +1,6 @@
// Package agent provides a unified interface for executing prompts via
// coding agents (Claude Code, Codex). It mirrors the happy-cli AgentBackend
// pattern, translated to idiomatic Go.
// coding agents (Claude Code, Codex, OpenCode). It mirrors the happy-cli
// AgentBackend pattern, translated to idiomatic Go.
package agent
import (
@@ -90,8 +90,10 @@ func New(agentType string, cfg Config) (Backend, error) {
return &claudeBackend{cfg: cfg}, nil
case "codex":
return &codexBackend{cfg: cfg}, nil
case "opencode":
return &opencodeBackend{cfg: cfg}, nil
default:
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex)", agentType)
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode)", agentType)
}
}

View File

@@ -27,6 +27,17 @@ func TestNewReturnsCodexBackend(t *testing.T) {
}
}
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)
}
}
func TestNewRejectsUnknownType(t *testing.T) {
t.Parallel()
_, err := New("gpt", Config{})

View File

@@ -0,0 +1,264 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// opencodeBackend implements Backend by spawning the OpenCode CLI
// with `opencode run --format json`.
type opencodeBackend struct {
cfg Config
}
func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "opencode"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("opencode executable not found at %q: %w", execPath, err)
}
timeout := opts.Timeout
if timeout == 0 {
timeout = 20 * time.Minute
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
args := []string{"run", "--format", "json"}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
args = append(args, prompt)
cmd := exec.CommandContext(runCtx, execPath, args...)
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("opencode stdout pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[opencode:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start opencode: %w", err)
}
b.cfg.Logger.Info("opencode started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
startTime := time.Now()
var output strings.Builder
var sessionID string
finalStatus := "completed"
var finalError string
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var evt opencodeEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
continue
}
if evt.SessionID != "" && sessionID == "" {
sessionID = evt.SessionID
}
switch evt.Type {
case "text":
b.handleText(evt, msgCh, &output)
case "tool_use":
b.handleToolUse(evt, msgCh)
case "step_start":
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
case "step_finish":
b.handleStepFinish(evt, msgCh)
case "error":
b.handleError(evt, msgCh, &finalStatus, &finalError)
}
}
// Wait for process exit
exitErr := cmd.Wait()
duration := time.Since(startTime)
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("opencode timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else if exitErr != nil && finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("opencode exited with error: %v", exitErr)
}
b.cfg.Logger.Info("opencode finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resCh <- Result{
Status: finalStatus,
Output: output.String(),
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
func (b *opencodeBackend) handleText(evt opencodeEvent, ch chan<- Message, output *strings.Builder) {
text := evt.Part.Text
if text == "" {
return
}
partType := evt.Part.Type
switch partType {
case "thinking":
trySend(ch, Message{Type: MessageThinking, Content: text})
default:
// "text" or any other type → treat as assistant text output
output.WriteString(text)
trySend(ch, Message{Type: MessageText, Content: text})
}
}
func (b *opencodeBackend) handleToolUse(evt opencodeEvent, ch chan<- Message) {
tool := evt.Part.Tool
callID := evt.Part.CallID
state := evt.Part.State
var input map[string]any
if state.Input != nil {
_ = json.Unmarshal(state.Input, &input)
}
if state.Status == "completed" {
// Emit both tool_use and tool_result for completed tool calls
trySend(ch, Message{
Type: MessageToolUse,
Tool: tool,
CallID: callID,
Input: input,
})
trySend(ch, Message{
Type: MessageToolResult,
Tool: tool,
CallID: callID,
Output: state.Output,
})
} else {
// Running or pending tool call
trySend(ch, Message{
Type: MessageToolUse,
Tool: tool,
CallID: callID,
Input: input,
})
}
}
func (b *opencodeBackend) handleStepFinish(evt opencodeEvent, ch chan<- Message) {
reason := evt.Part.Reason
status := "step completed"
if reason != "" {
status = fmt.Sprintf("step finished: %s", reason)
}
trySend(ch, Message{Type: MessageStatus, Status: status})
}
func (b *opencodeBackend) handleError(evt opencodeEvent, ch chan<- Message, finalStatus, finalError *string) {
errMsg := ""
if evt.Error != nil {
errMsg = evt.Error.Name
if evt.Error.Data.Message != "" {
errMsg = evt.Error.Data.Message
}
}
if errMsg != "" {
*finalStatus = "failed"
*finalError = errMsg
trySend(ch, Message{Type: MessageError, Content: errMsg})
}
}
// ── OpenCode JSON event types ──
// opencodeEvent represents a single JSONL event from `opencode run --format json`.
type opencodeEvent struct {
Type string `json:"type"`
Timestamp int64 `json:"timestamp,omitempty"`
SessionID string `json:"sessionID,omitempty"`
Part opencodePartData `json:"part"`
Error *opencodeError `json:"error,omitempty"`
}
// opencodePartData holds the "part" payload — its fields vary by event type.
type opencodePartData struct {
ID string `json:"id,omitempty"`
SessionID string `json:"sessionID,omitempty"`
MessageID string `json:"messageID,omitempty"`
Type string `json:"type,omitempty"` // "text", "thinking", "step-start", "step-finish"
Text string `json:"text,omitempty"` // for text events
Tool string `json:"tool,omitempty"` // for tool_use events (e.g. "bash", "read")
CallID string `json:"callID,omitempty"` // unique tool call ID
Snapshot string `json:"snapshot,omitempty"`
Reason string `json:"reason,omitempty"` // "stop", "tool-calls" — for step_finish
Cost float64 `json:"cost,omitempty"`
State opencodeToolState `json:"state"` // for tool_use events
Tokens *opencodeTokens `json:"tokens,omitempty"`
}
// opencodeToolState holds tool execution state within a tool_use event.
type opencodeToolState struct {
Status string `json:"status,omitempty"` // "completed", "running"
Input json.RawMessage `json:"input,omitempty"`
Output string `json:"output,omitempty"`
Title string `json:"title,omitempty"`
Time float64 `json:"time,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
// opencodeTokens tracks token usage from step_finish events.
type opencodeTokens struct {
Input int `json:"input,omitempty"`
Output int `json:"output,omitempty"`
Reasoning int `json:"reasoning,omitempty"`
Cache int `json:"cache,omitempty"`
}
// opencodeError holds error details from error events.
type opencodeError struct {
Name string `json:"name,omitempty"`
Data struct {
Message string `json:"message,omitempty"`
} `json:"data,omitempty"`
}

View File

@@ -0,0 +1,289 @@
package agent
import (
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestOpencodeHandleText(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
evt := opencodeEvent{
Type: "text",
SessionID: "ses_abc123",
Part: opencodePartData{
Type: "text",
Text: "Hello from OpenCode",
},
}
b.handleText(evt, ch, &output)
if output.String() != "Hello from OpenCode" {
t.Fatalf("expected output 'Hello from OpenCode', got %q", output.String())
}
select {
case m := <-ch:
if m.Type != MessageText || m.Content != "Hello from OpenCode" {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestOpencodeHandleTextThinking(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
evt := opencodeEvent{
Type: "text",
Part: opencodePartData{
Type: "thinking",
Text: "Let me think about this...",
},
}
b.handleText(evt, ch, &output)
// Thinking text should NOT go to output
if output.String() != "" {
t.Fatalf("expected empty output for thinking, got %q", output.String())
}
select {
case m := <-ch:
if m.Type != MessageThinking || m.Content != "Let me think about this..." {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestOpencodeHandleTextEmpty(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
evt := opencodeEvent{
Type: "text",
Part: opencodePartData{Type: "text", Text: ""},
}
b.handleText(evt, ch, &output)
if output.String() != "" {
t.Fatalf("expected empty output, got %q", output.String())
}
select {
case m := <-ch:
t.Fatalf("expected no message for empty text, got %+v", m)
default:
}
}
func TestOpencodeHandleToolUseCompleted(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
evt := opencodeEvent{
Type: "tool_use",
Part: opencodePartData{
Tool: "bash",
CallID: "call-42",
State: opencodeToolState{
Status: "completed",
Input: mustMarshal(t, map[string]any{"command": "ls -la"}),
Output: "total 8\ndrwxr-xr-x 2 user user 4096 ...",
},
},
}
b.handleToolUse(evt, ch)
// Should emit both tool_use and tool_result
m1 := <-ch
if m1.Type != MessageToolUse || m1.Tool != "bash" || m1.CallID != "call-42" {
t.Fatalf("unexpected tool_use message: %+v", m1)
}
if m1.Input["command"] != "ls -la" {
t.Fatalf("expected input command 'ls -la', got %v", m1.Input["command"])
}
m2 := <-ch
if m2.Type != MessageToolResult || m2.CallID != "call-42" {
t.Fatalf("unexpected tool_result message: %+v", m2)
}
if !strings.Contains(m2.Output, "total 8") {
t.Fatalf("expected output containing 'total 8', got %q", m2.Output)
}
}
func TestOpencodeHandleToolUseRunning(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
evt := opencodeEvent{
Type: "tool_use",
Part: opencodePartData{
Tool: "read",
CallID: "call-99",
State: opencodeToolState{
Status: "running",
Input: mustMarshal(t, map[string]any{"path": "/tmp/test.go"}),
},
},
}
b.handleToolUse(evt, ch)
// Should emit only tool_use (no result yet)
m := <-ch
if m.Type != MessageToolUse || m.Tool != "read" || m.CallID != "call-99" {
t.Fatalf("unexpected message: %+v", m)
}
select {
case extra := <-ch:
t.Fatalf("expected no extra message for running tool, got %+v", extra)
default:
}
}
func TestOpencodeHandleStepFinish(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
evt := opencodeEvent{
Type: "step_finish",
Part: opencodePartData{
Type: "step-finish",
Reason: "stop",
},
}
b.handleStepFinish(evt, ch)
m := <-ch
if m.Type != MessageStatus || !strings.Contains(m.Status, "stop") {
t.Fatalf("unexpected message: %+v", m)
}
}
func TestOpencodeHandleError(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
finalStatus := "completed"
finalError := ""
evt := opencodeEvent{
Type: "error",
Error: &opencodeError{
Name: "RateLimitError",
},
}
evt.Error.Data.Message = "rate limit exceeded"
b.handleError(evt, ch, &finalStatus, &finalError)
if finalStatus != "failed" {
t.Fatalf("expected status 'failed', got %q", finalStatus)
}
if finalError != "rate limit exceeded" {
t.Fatalf("expected error 'rate limit exceeded', got %q", finalError)
}
m := <-ch
if m.Type != MessageError || m.Content != "rate limit exceeded" {
t.Fatalf("unexpected message: %+v", m)
}
}
func TestOpencodeHandleErrorNameOnly(t *testing.T) {
t.Parallel()
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
finalStatus := "completed"
finalError := ""
evt := opencodeEvent{
Type: "error",
Error: &opencodeError{
Name: "UnknownError",
},
}
b.handleError(evt, ch, &finalStatus, &finalError)
if finalError != "UnknownError" {
t.Fatalf("expected error 'UnknownError', got %q", finalError)
}
}
func TestOpencodeEventParsing(t *testing.T) {
t.Parallel()
raw := `{"type":"text","timestamp":1767036059338,"sessionID":"ses_abc","part":{"type":"text","text":"Hello"}}`
var evt opencodeEvent
if err := json.Unmarshal([]byte(raw), &evt); err != nil {
t.Fatalf("failed to parse event: %v", err)
}
if evt.Type != "text" {
t.Fatalf("expected type 'text', got %q", evt.Type)
}
if evt.SessionID != "ses_abc" {
t.Fatalf("expected sessionID 'ses_abc', got %q", evt.SessionID)
}
if evt.Part.Text != "Hello" {
t.Fatalf("expected part.text 'Hello', got %q", evt.Part.Text)
}
}
func TestOpencodeToolUseEventParsing(t *testing.T) {
t.Parallel()
raw := `{"type":"tool_use","timestamp":1767036060000,"sessionID":"ses_xyz","part":{"callID":"call-1","tool":"bash","state":{"status":"completed","input":{"command":"echo hi"},"output":"hi\n","time":0.5}}}`
var evt opencodeEvent
if err := json.Unmarshal([]byte(raw), &evt); err != nil {
t.Fatalf("failed to parse event: %v", err)
}
if evt.Type != "tool_use" {
t.Fatalf("expected type 'tool_use', got %q", evt.Type)
}
if evt.Part.Tool != "bash" {
t.Fatalf("expected tool 'bash', got %q", evt.Part.Tool)
}
if evt.Part.CallID != "call-1" {
t.Fatalf("expected callID 'call-1', got %q", evt.Part.CallID)
}
if evt.Part.State.Status != "completed" {
t.Fatalf("expected state.status 'completed', got %q", evt.Part.State.Status)
}
if evt.Part.State.Output != "hi\n" {
t.Fatalf("expected state.output 'hi\\n', got %q", evt.Part.State.Output)
}
}