diff --git a/.gitignore b/.gitignore index b5e1c38a..88cc9844 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ apps/web/test-results/ # local settings .claude/ +.tool-versions # feature tracking _features/ diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index e8a033ab..bb41e30e 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -11,15 +11,15 @@ import ( ) const ( - DefaultServerURL = "ws://localhost:8080/ws" - DefaultPollInterval = 3 * time.Second - DefaultHeartbeatInterval = 15 * time.Second - DefaultAgentTimeout = 2 * time.Hour - DefaultRuntimeName = "Local Agent" + DefaultServerURL = "ws://localhost:8080/ws" + DefaultPollInterval = 3 * time.Second + DefaultHeartbeatInterval = 15 * time.Second + DefaultAgentTimeout = 2 * time.Hour + DefaultRuntimeName = "Local Agent" DefaultConfigReloadInterval = 5 * time.Second DefaultWorkspaceSyncInterval = 30 * time.Second DefaultHealthPort = 19514 - DefaultMaxConcurrentTasks = 20 + DefaultMaxConcurrentTasks = 20 ) // Config holds all daemon configuration. @@ -30,7 +30,7 @@ type Config struct { RuntimeName string CLIVersion string // multica CLI version (e.g. "0.1.13") Profile string // profile name (empty = default) - Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry + Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry, "opencode" -> entry WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces) KeepEnvAfterTask bool // preserve env after task for debugging HealthPort int // local HTTP port for health checks (default: 19514) @@ -85,8 +85,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 diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index c59d56e9..f0bd453f 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -11,9 +11,10 @@ import ( // writeContextFiles renders and writes .agent_context/issue_context.md and // skills into the appropriate provider-native location. // -// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery) -// Codex: skills → handled separately in Prepare via codex-home -// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md +// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery) +// Codex: skills → handled separately in Prepare via codex-home +// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery) +// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error { contextDir := filepath.Join(workDir, ".agent_context") if err := os.MkdirAll(contextDir, 0o755); err != nil { @@ -50,6 +51,9 @@ func resolveSkillsDir(workDir, provider string) (string, error) { case "claude": // Claude Code natively discovers skills from .claude/skills/ in the workdir. skillsDir = filepath.Join(workDir, ".claude", "skills") + case "opencode": + // OpenCode natively discovers skills from .config/opencode/skills/ in the workdir. + skillsDir = filepath.Join(workDir, ".config", "opencode", "skills") default: // Fallback: write to .agent_context/skills/ (referenced by meta config). skillsDir = filepath.Join(workDir, ".agent_context", "skills") diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index f163ef47..462ece37 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -441,6 +441,148 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) { } } +func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + ctx := TaskContextForEnv{ + IssueID: "opencode-skill-test", + AgentSkills: []SkillContextForEnv{ + { + Name: "Go Conventions", + Content: "Follow Go conventions.", + Files: []SkillFileContextForEnv{ + {Path: "templates/example.go", Content: "package main"}, + }, + }, + }, + } + + if err := writeContextFiles(dir, "opencode", ctx); err != nil { + t.Fatalf("writeContextFiles failed: %v", err) + } + + // Skills should be in .config/opencode/skills/ (native discovery). + skillMd, err := os.ReadFile(filepath.Join(dir, ".config", "opencode", "skills", "go-conventions", "SKILL.md")) + if err != nil { + t.Fatalf("failed to read .config/opencode/skills/go-conventions/SKILL.md: %v", err) + } + if !strings.Contains(string(skillMd), "Follow Go conventions.") { + t.Error("SKILL.md missing content") + } + + // Supporting files should also be under .config/opencode/skills/. + supportFile, err := os.ReadFile(filepath.Join(dir, ".config", "opencode", "skills", "go-conventions", "templates", "example.go")) + if err != nil { + t.Fatalf("failed to read supporting file: %v", err) + } + if string(supportFile) != "package main" { + t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main") + } + + // .agent_context/skills/ should NOT exist for OpenCode. + if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) { + t.Error("expected .agent_context/skills/ to NOT exist for OpenCode provider") + } + + // issue_context.md should still be in .agent_context/. + if _, err := os.Stat(filepath.Join(dir, ".agent_context", "issue_context.md")); os.IsNotExist(err) { + t.Error("expected .agent_context/issue_context.md to exist") + } +} + +func TestInjectRuntimeConfigOpencode(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + ctx := TaskContextForEnv{ + IssueID: "test-issue-id", + AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}}, + } + + if err := InjectRuntimeConfig(dir, "opencode", ctx); err != nil { + t.Fatalf("InjectRuntimeConfig failed: %v", err) + } + + // OpenCode uses AGENTS.md (same as codex). + content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + if err != nil { + t.Fatalf("failed to read AGENTS.md: %v", err) + } + + s := string(content) + if !strings.Contains(s, "Multica Agent Runtime") { + t.Error("AGENTS.md missing meta skill header") + } + if !strings.Contains(s, "Coding") { + t.Error("AGENTS.md missing skill name") + } + if !strings.Contains(s, "discovered automatically") { + t.Error("AGENTS.md missing native skill discovery hint") + } + + // CLAUDE.md should NOT exist. + if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) { + t.Error("expected CLAUDE.md to NOT exist for OpenCode provider") + } +} + +func TestPrepareWithRepoContextOpencode(t *testing.T) { + t.Parallel() + workspacesRoot := t.TempDir() + + taskCtx := TaskContextForEnv{ + IssueID: "c3d4e5f6-a7b8-9012-cdef-123456789012", + Repos: []RepoContextForEnv{ + {URL: "https://github.com/org/backend", Description: "Go backend"}, + }, + } + env, err := Prepare(PrepareParams{ + WorkspacesRoot: workspacesRoot, + WorkspaceID: "ws-test-oc", + TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012", + AgentName: "OpenCode Agent", + Provider: "opencode", + Task: taskCtx, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + defer env.Cleanup(true) + + if err := InjectRuntimeConfig(env.WorkDir, "opencode", taskCtx); err != nil { + t.Fatalf("InjectRuntimeConfig failed: %v", err) + } + + // Workdir should only contain expected entries. + entries, err := os.ReadDir(env.WorkDir) + if err != nil { + t.Fatalf("failed to read workdir: %v", err) + } + for _, e := range entries { + name := e.Name() + if name != ".agent_context" && name != "AGENTS.md" { + t.Errorf("unexpected entry in workdir: %s", name) + } + } + + // AGENTS.md should contain repo info. + content, err := os.ReadFile(filepath.Join(env.WorkDir, "AGENTS.md")) + if err != nil { + t.Fatalf("failed to read AGENTS.md: %v", err) + } + s := string(content) + for _, want := range []string{ + "multica repo checkout", + "https://github.com/org/backend", + "Go backend", + } { + if !strings.Contains(s, want) { + t.Errorf("AGENTS.md missing %q", want) + } + } +} + func TestInjectRuntimeConfigUnknownProvider(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 4d4e604b..1f816f12 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -10,15 +10,16 @@ import ( // InjectRuntimeConfig writes the meta skill content into the runtime-specific // config file so the agent discovers its environment through its native mechanism. // -// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/) -// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME) +// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/) +// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME) +// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/) func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error { content := buildMetaSkillContent(provider, ctx) switch provider { case "claude": return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644) - case "codex": + case "codex", "opencode": return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644) default: // Unknown provider — skip config injection, prompt-only mode. @@ -114,8 +115,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { case "claude": // Claude discovers skills natively from .claude/skills/ — just list names. b.WriteString("You have the following skills installed (discovered automatically):\n\n") - case "codex": - // Codex discovers skills natively via CODEX_HOME/skills/ — just list names. + case "codex", "opencode": + // Codex and OpenCode discover skills natively from their respective paths — just list names. b.WriteString("You have the following skills installed (discovered automatically):\n\n") default: b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n") diff --git a/server/internal/daemon/repocache/cache.go b/server/internal/daemon/repocache/cache.go index ef8c5160..b37c7c37 100644 --- a/server/internal/daemon/repocache/cache.go +++ b/server/internal/daemon/repocache/cache.go @@ -29,7 +29,7 @@ type CachedRepo struct { // Cache manages bare git clones for workspace repositories. type Cache struct { - root string // base directory for all caches (e.g. ~/multica_workspaces/.repos) + root string // base directory for all caches (e.g. ~/multica_workspaces/.repos) logger *slog.Logger mu sync.Mutex } @@ -195,7 +195,7 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) { } // Exclude agent context files from git tracking. - for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude"} { + for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude", ".config/opencode"} { _ = excludeFromGit(worktreePath, pattern) } diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index d80a2641..383d6fe1 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -1,5 +1,5 @@ // Package agent provides a unified interface for executing prompts via -// coding agents (Claude Code, Codex). It mirrors the happy-cli AgentBackend +// coding agents (Claude Code, Codex, OpenCode). It mirrors the happy-cli AgentBackend // pattern, translated to idiomatic Go. package agent @@ -25,7 +25,7 @@ type ExecOptions struct { SystemPrompt string MaxTurns int Timeout time.Duration - ResumeSessionID string // if non-empty, resume a previous Claude Code session + ResumeSessionID string // if non-empty, resume a previous agent session } // Session represents a running agent execution. @@ -73,13 +73,13 @@ type Result struct { // Config configures a Backend instance. type Config struct { - ExecutablePath string // path to CLI binary (claude or codex) + ExecutablePath string // path to CLI binary (claude, codex, or opencode) Env map[string]string // extra environment variables Logger *slog.Logger } // New creates a Backend for the given agent type. -// Supported types: "claude", "codex". +// Supported types: "claude", "codex", "opencode". func New(agentType string, cfg Config) (Backend, error) { if cfg.Logger == nil { cfg.Logger = slog.Default() @@ -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) } } diff --git a/server/pkg/agent/opencode.go b/server/pkg/agent/opencode.go new file mode 100644 index 00000000..66b678f5 --- /dev/null +++ b/server/pkg/agent/opencode.go @@ -0,0 +1,312 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "strings" + "time" +) + +// opencodeBackend implements Backend by spawning `opencode run --format json` +// and reading streaming JSON events from stdout — the same pattern as Claude. +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) + } + if opts.SystemPrompt != "" { + args = append(args, "--prompt", opts.SystemPrompt) + } + if opts.MaxTurns > 0 { + b.cfg.Logger.Warn("opencode does not support --max-turns; ignoring", "maxTurns", opts.MaxTurns) + } + if opts.ResumeSessionID != "" { + args = append(args, "--session", opts.ResumeSessionID) + } + args = append(args, prompt) + + cmd := exec.CommandContext(runCtx, execPath, args...) + if opts.Cwd != "" { + cmd.Dir = opts.Cwd + } + + env := buildEnv(b.cfg.Env) + // Auto-approve all tool use in daemon mode. + env = append(env, `OPENCODE_PERMISSION={"*":"allow"}`) + cmd.Env = 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() + scanResult := b.processEvents(stdout, msgCh) + + // Wait for process exit. + exitErr := cmd.Wait() + duration := time.Since(startTime) + + if runCtx.Err() == context.DeadlineExceeded { + scanResult.status = "timeout" + scanResult.errMsg = fmt.Sprintf("opencode timed out after %s", timeout) + } else if runCtx.Err() == context.Canceled { + scanResult.status = "aborted" + scanResult.errMsg = "execution cancelled" + } else if exitErr != nil && scanResult.status == "completed" { + scanResult.status = "failed" + scanResult.errMsg = fmt.Sprintf("opencode exited with error: %v", exitErr) + } + + b.cfg.Logger.Info("opencode finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String()) + + resCh <- Result{ + Status: scanResult.status, + Output: scanResult.output, + Error: scanResult.errMsg, + DurationMs: duration.Milliseconds(), + SessionID: scanResult.sessionID, + } + }() + + return &Session{Messages: msgCh, Result: resCh}, nil +} + +// ── Event handlers ── + +// eventResult holds the accumulated state from processing the event stream. +type eventResult struct { + status string + errMsg string + output string + sessionID string +} + +// processEvents reads JSON lines from r, dispatches events to ch, and returns +// the accumulated result. This is the core scanner loop, extracted for testability. +func (b *opencodeBackend) processEvents(r io.Reader, ch chan<- Message) eventResult { + var output strings.Builder + var sessionID string + finalStatus := "completed" + var finalError string + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var event opencodeEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + continue + } + + if event.SessionID != "" { + sessionID = event.SessionID + } + + switch event.Type { + case "text": + b.handleTextEvent(event, ch, &output) + case "tool_use": + b.handleToolUseEvent(event, ch) + case "error": + b.handleErrorEvent(event, ch, &finalStatus, &finalError) + case "step_start": + trySend(ch, Message{Type: MessageStatus, Status: "running"}) + case "step_finish": + // Captures final session ID from step_finish if present. + } + } + + // Check for scanner errors (e.g. broken pipe, read errors). + if scanErr := scanner.Err(); scanErr != nil { + b.cfg.Logger.Warn("opencode stdout scanner error", "error", scanErr) + if finalStatus == "completed" { + finalStatus = "failed" + finalError = fmt.Sprintf("stdout read error: %v", scanErr) + } + } + + return eventResult{ + status: finalStatus, + errMsg: finalError, + output: output.String(), + sessionID: sessionID, + } +} + +func (b *opencodeBackend) handleTextEvent(event opencodeEvent, ch chan<- Message, output *strings.Builder) { + text := event.Part.Text + if text != "" { + output.WriteString(text) + trySend(ch, Message{Type: MessageText, Content: text}) + } +} + +// handleToolUseEvent processes "tool_use" events from opencode. A single +// tool_use event contains both the call and result in part.state when the +// tool has completed (state.status == "completed"). +func (b *opencodeBackend) handleToolUseEvent(event opencodeEvent, ch chan<- Message) { + // Extract input from state.input (the tool invocation parameters). + var input map[string]any + if event.Part.State != nil && event.Part.State.Input != nil { + _ = json.Unmarshal(event.Part.State.Input, &input) + } + + // Emit the tool-use message. + trySend(ch, Message{ + Type: MessageToolUse, + Tool: event.Part.Tool, + CallID: event.Part.CallID, + Input: input, + }) + + // If the tool has completed, also emit a tool-result message. + if event.Part.State != nil && event.Part.State.Status == "completed" { + outputStr := extractToolOutput(event.Part.State.Output) + trySend(ch, Message{ + Type: MessageToolResult, + Tool: event.Part.Tool, + CallID: event.Part.CallID, + Output: outputStr, + }) + } +} + +// handleErrorEvent processes "error" events from opencode. OpenCode can exit +// with RC=0 even on errors (e.g. invalid model), so error events are the +// reliable signal for failures. +func (b *opencodeBackend) handleErrorEvent(event opencodeEvent, ch chan<- Message, finalStatus, finalError *string) { + errMsg := "" + if event.Error != nil { + errMsg = event.Error.Message() + } + if errMsg == "" { + errMsg = "unknown opencode error" + } + + b.cfg.Logger.Warn("opencode error event", "error", errMsg) + trySend(ch, Message{Type: MessageError, Content: errMsg}) + + *finalStatus = "failed" + *finalError = errMsg +} + +// extractToolOutput converts the tool state output (which may be a string or +// structured object) into a string. +func extractToolOutput(output any) string { + if output == nil { + return "" + } + if s, ok := output.(string); ok { + return s + } + data, _ := json.Marshal(output) + return string(data) +} + +// ── JSON types for `opencode run --format json` stdout events ── + +// opencodeEvent represents a single JSON line from `opencode run --format json`. +// +// Event types observed in real output: +// +// "step_start" — agent step begins +// "text" — text output from agent (part.text) +// "tool_use" — tool invocation with call and result (part.tool, part.callID, part.state) +// "error" — error from opencode (error.name, error.data.message) +// "step_finish" — agent step completes (includes token usage) +type opencodeEvent struct { + Type string `json:"type"` + Timestamp int64 `json:"timestamp,omitempty"` + SessionID string `json:"sessionID,omitempty"` + Part opencodeEventPart `json:"part"` + Error *opencodeError `json:"error,omitempty"` +} + +// opencodeEventPart represents the part field in an opencode event. +type opencodeEventPart struct { + ID string `json:"id,omitempty"` + MessageID string `json:"messageID,omitempty"` + SessionID string `json:"sessionID,omitempty"` + Type string `json:"type,omitempty"` + + // Text events + Text string `json:"text,omitempty"` + + // Tool use events + Tool string `json:"tool,omitempty"` + CallID string `json:"callID,omitempty"` + State *opencodeToolState `json:"state,omitempty"` +} + +// opencodeToolState represents the state of a tool invocation. +type opencodeToolState struct { + Status string `json:"status,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Output any `json:"output,omitempty"` +} + +// opencodeError represents an error event from opencode. +type opencodeError struct { + Name string `json:"name,omitempty"` + Data *opencodeErrData `json:"data,omitempty"` +} + +// Message returns the human-readable error message. +func (e *opencodeError) Message() string { + if e.Data != nil && e.Data.Message != "" { + return e.Data.Message + } + if e.Name != "" { + return e.Name + } + return "" +} + +type opencodeErrData struct { + Message string `json:"message,omitempty"` +} diff --git a/server/pkg/agent/opencode_test.go b/server/pkg/agent/opencode_test.go new file mode 100644 index 00000000..73c6d491 --- /dev/null +++ b/server/pkg/agent/opencode_test.go @@ -0,0 +1,711 @@ +package agent + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + "testing" +) + +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) +}