feat(daemon): add opencode as supported agent provider (#341)
* feat(daemon): add opencode as supported agent provider Add opencode backend alongside claude and codex. The backend spawns `opencode run --format json`, parses streaming JSON events (text, tool_use, error, step_start/finish), and supports --prompt for system prompts. Includes CLI detection, AGENTS.md runtime config, native skill discovery via .config/opencode/skills/, and 21 tests covering handlers, JSON parsing, and integration-level processEvents scenarios. * chore: add .tool-versions to gitignore
This commit is contained in:
parent
09764c5f51
commit
36db325d50
9 changed files with 1203 additions and 23 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -36,6 +36,7 @@ apps/web/test-results/
|
||||||
|
|
||||||
# local settings
|
# local settings
|
||||||
.claude/
|
.claude/
|
||||||
|
.tool-versions
|
||||||
|
|
||||||
# feature tracking
|
# feature tracking
|
||||||
_features/
|
_features/
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultServerURL = "ws://localhost:8080/ws"
|
DefaultServerURL = "ws://localhost:8080/ws"
|
||||||
DefaultPollInterval = 3 * time.Second
|
DefaultPollInterval = 3 * time.Second
|
||||||
DefaultHeartbeatInterval = 15 * time.Second
|
DefaultHeartbeatInterval = 15 * time.Second
|
||||||
DefaultAgentTimeout = 2 * time.Hour
|
DefaultAgentTimeout = 2 * time.Hour
|
||||||
DefaultRuntimeName = "Local Agent"
|
DefaultRuntimeName = "Local Agent"
|
||||||
DefaultConfigReloadInterval = 5 * time.Second
|
DefaultConfigReloadInterval = 5 * time.Second
|
||||||
DefaultWorkspaceSyncInterval = 30 * time.Second
|
DefaultWorkspaceSyncInterval = 30 * time.Second
|
||||||
DefaultHealthPort = 19514
|
DefaultHealthPort = 19514
|
||||||
DefaultMaxConcurrentTasks = 20
|
DefaultMaxConcurrentTasks = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all daemon configuration.
|
// Config holds all daemon configuration.
|
||||||
|
|
@ -30,7 +30,7 @@ type Config struct {
|
||||||
RuntimeName string
|
RuntimeName string
|
||||||
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
||||||
Profile string // profile name (empty = default)
|
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)
|
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
||||||
KeepEnvAfterTask bool // preserve env after task for debugging
|
KeepEnvAfterTask bool // preserve env after task for debugging
|
||||||
HealthPort int // local HTTP port for health checks (default: 19514)
|
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")),
|
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 {
|
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
|
// Host info
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ import (
|
||||||
// writeContextFiles renders and writes .agent_context/issue_context.md and
|
// writeContextFiles renders and writes .agent_context/issue_context.md and
|
||||||
// skills into the appropriate provider-native location.
|
// skills into the appropriate provider-native location.
|
||||||
//
|
//
|
||||||
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
||||||
// Codex: skills → handled separately in Prepare via codex-home
|
// Codex: skills → handled separately in Prepare via codex-home
|
||||||
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
|
// 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 {
|
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
|
||||||
contextDir := filepath.Join(workDir, ".agent_context")
|
contextDir := filepath.Join(workDir, ".agent_context")
|
||||||
if err := os.MkdirAll(contextDir, 0o755); err != nil {
|
if err := os.MkdirAll(contextDir, 0o755); err != nil {
|
||||||
|
|
@ -50,6 +51,9 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
|
||||||
case "claude":
|
case "claude":
|
||||||
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
|
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
|
||||||
skillsDir = filepath.Join(workDir, ".claude", "skills")
|
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:
|
default:
|
||||||
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
||||||
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
|
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,16 @@ import (
|
||||||
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
|
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
|
||||||
// config file so the agent discovers its environment through its native mechanism.
|
// 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 Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
|
||||||
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
|
// 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 {
|
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
|
||||||
content := buildMetaSkillContent(provider, ctx)
|
content := buildMetaSkillContent(provider, ctx)
|
||||||
|
|
||||||
switch provider {
|
switch provider {
|
||||||
case "claude":
|
case "claude":
|
||||||
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
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)
|
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||||
default:
|
default:
|
||||||
// Unknown provider — skip config injection, prompt-only mode.
|
// Unknown provider — skip config injection, prompt-only mode.
|
||||||
|
|
@ -114,8 +115,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||||
case "claude":
|
case "claude":
|
||||||
// Claude discovers skills natively from .claude/skills/ — just list names.
|
// Claude discovers skills natively from .claude/skills/ — just list names.
|
||||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||||
case "codex":
|
case "codex", "opencode":
|
||||||
// Codex discovers skills natively via CODEX_HOME/skills/ — just list names.
|
// 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")
|
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||||
default:
|
default:
|
||||||
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
|
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ type CachedRepo struct {
|
||||||
|
|
||||||
// Cache manages bare git clones for workspace repositories.
|
// Cache manages bare git clones for workspace repositories.
|
||||||
type Cache struct {
|
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
|
logger *slog.Logger
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +195,7 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude agent context files from git tracking.
|
// 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)
|
_ = excludeFromGit(worktreePath, pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Package agent provides a unified interface for executing prompts via
|
// 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.
|
// pattern, translated to idiomatic Go.
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ type ExecOptions struct {
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
MaxTurns int
|
MaxTurns int
|
||||||
Timeout time.Duration
|
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.
|
// Session represents a running agent execution.
|
||||||
|
|
@ -73,13 +73,13 @@ type Result struct {
|
||||||
|
|
||||||
// Config configures a Backend instance.
|
// Config configures a Backend instance.
|
||||||
type Config struct {
|
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
|
Env map[string]string // extra environment variables
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Backend for the given agent type.
|
// 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) {
|
func New(agentType string, cfg Config) (Backend, error) {
|
||||||
if cfg.Logger == nil {
|
if cfg.Logger == nil {
|
||||||
cfg.Logger = slog.Default()
|
cfg.Logger = slog.Default()
|
||||||
|
|
@ -90,8 +90,10 @@ func New(agentType string, cfg Config) (Backend, error) {
|
||||||
return &claudeBackend{cfg: cfg}, nil
|
return &claudeBackend{cfg: cfg}, nil
|
||||||
case "codex":
|
case "codex":
|
||||||
return &codexBackend{cfg: cfg}, nil
|
return &codexBackend{cfg: cfg}, nil
|
||||||
|
case "opencode":
|
||||||
|
return &opencodeBackend{cfg: cfg}, nil
|
||||||
default:
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
312
server/pkg/agent/opencode.go
Normal file
312
server/pkg/agent/opencode.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
711
server/pkg/agent/opencode_test.go
Normal file
711
server/pkg/agent/opencode_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue