feat: opencode acp

This commit is contained in:
Carlos Florêncio
2026-02-21 21:54:56 +00:00
parent f0d84b32b8
commit 4e2e59b1ec
11 changed files with 138 additions and 26 deletions
@@ -0,0 +1,81 @@
package agents
import (
"context"
"time"
"github.com/kandev/kandev/pkg/agent"
)
var _ Agent = (*OpenCodeACP)(nil)
// OpenCodeACP is the ACP protocol variant of OpenCode.
// Uses JSON-RPC 2.0 over stdin/stdout via "opencode acp" instead of REST/SSE.
type OpenCodeACP struct{}
func NewOpenCodeACP() *OpenCodeACP { return &OpenCodeACP{} }
func (a *OpenCodeACP) ID() string { return "opencode-acp" }
func (a *OpenCodeACP) Name() string { return "OpenCode AI Agent (ACP)" }
func (a *OpenCodeACP) DisplayName() string { return "OpenCode (ACP)" }
func (a *OpenCodeACP) Description() string {
return "OpenCode coding agent using ACP protocol over stdin/stdout."
}
func (a *OpenCodeACP) Enabled() bool { return true }
func (a *OpenCodeACP) DisplayOrder() int { return 5 }
func (a *OpenCodeACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return opencodeLogoDark
}
return opencodeLogoLight
}
func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
// Same detection as SSE variant — both share the same binary
install := OSPaths{
Linux: []string{"~/.opencode", "~/.config/opencode"},
MacOS: []string{"~/.opencode", "~/.config/opencode", "~/Library/Application Support/ai.opencode.desktop"},
}
result, err := Detect(ctx, WithFileExists(install.Resolve()...))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.InstallationPaths = install.Expanded()
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *OpenCodeACP) DefaultModel() string { return "opencode/gpt-5-nano" }
func (a *OpenCodeACP) ListModels(ctx context.Context) (*ModelList, error) {
return (&OpenCode{}).ListModels(ctx)
}
func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command {
return Cmd("opencode", "acp").Build()
}
func (a *OpenCodeACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd("opencode", "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.opencode",
},
}
}
func (a *OpenCodeACP) PermissionSettings() map[string]PermissionSetting {
return opencodePermSettings
}
@@ -53,7 +53,7 @@ func buildLaunchMetadata(req *LaunchRequest, mainRepoGitDir, worktreeID, worktre
// agentCommands holds the initial and continue command strings for an agent execution.
type agentCommands struct {
initial string
initial string
continue_ string // continue command for one-shot agents (empty if not applicable)
}
+14 -14
View File
@@ -21,23 +21,23 @@ const AgentCtlPort = 9999
// AgentExecution represents a running agent execution
type AgentExecution struct {
ID string
TaskID string
SessionID string
AgentProfileID string
ContainerID string
ContainerIP string // IP address of the container for agentctl communication
WorkspacePath string // Path to the workspace (worktree or repository path)
ACPSessionID string // ACP session ID to resume, if available
ID string
TaskID string
SessionID string
AgentProfileID string
ContainerID string
ContainerIP string // IP address of the container for agentctl communication
WorkspacePath string // Path to the workspace (worktree or repository path)
ACPSessionID string // ACP session ID to resume, if available
AgentCommand string // Command to start the agent subprocess
ContinueCommand string // Command for follow-up prompts (one-shot agents like Amp)
RuntimeName string // Name of the runtime used (e.g., "docker", "standalone")
Status v1.AgentStatus
StartedAt time.Time
FinishedAt *time.Time
ExitCode *int
ErrorMessage string
Metadata map[string]interface{}
Status v1.AgentStatus
StartedAt time.Time
FinishedAt *time.Time
ExitCode *int
ErrorMessage string
Metadata map[string]interface{}
// agentctl client for this execution
agentctl *agentctl.Client
@@ -51,6 +51,7 @@ func (r *Registry) LoadDefaults() {
agents.NewCopilot(),
agents.NewGemini(),
agents.NewOpenCode(),
agents.NewOpenCodeACP(),
agents.NewAmp(),
agents.NewMockAgent(),
}
@@ -1119,6 +1119,6 @@ func (a *AmpAdapter) IsOneShot() bool {
// Verify interface implementation.
var (
_ AgentAdapter = (*AmpAdapter)(nil)
_ AgentAdapter = (*AmpAdapter)(nil)
_ OneShotAdapter = (*AmpAdapter)(nil)
)
@@ -12,7 +12,7 @@ import (
// auggieCommand is the CLI command for Auggie in ACP mode.
// Derived from internal/agent/agents/auggie.go.
const auggieCommand = "npx -y @augmentcode/auggie@0.16.2 --acp"
const auggieCommand = "npx -y @augmentcode/auggie --acp"
func TestAuggie_BasicPrompt(t *testing.T) {
result := RunAgent(t, AgentSpec{
@@ -12,7 +12,7 @@ import (
// geminiCommand is the CLI command for Google Gemini in ACP mode.
// Derived from internal/agent/agents/gemini.go.
const geminiCommand = "npx -y @google/gemini-cli@0.29.5 --experimental-acp"
const geminiCommand = "npx -y @google/gemini-cli --experimental-acp"
func TestGemini_BasicPrompt(t *testing.T) {
result := RunAgent(t, AgentSpec{
@@ -0,0 +1,30 @@
//go:build e2e
package e2e
import (
"testing"
"github.com/kandev/kandev/pkg/agent"
)
// openCodeACPCommand is the CLI command for OpenCode in ACP mode (JSON-RPC over stdin/stdout).
// Derived from internal/agent/agents/opencode_acp.go.
const openCodeACPCommand = "npx -y opencode-ai acp"
func TestOpenCodeACP_BasicPrompt(t *testing.T) {
result := RunAgent(t, AgentSpec{
Name: "opencode-acp",
Command: openCodeACPCommand,
Protocol: agent.ProtocolACP,
DefaultPrompt: "What is 2 + 2? Reply with just the number.",
AutoApprove: true,
})
defer DumpEventsOnFailure(t, result)
AssertTurnCompleted(t, result)
AssertSessionIDConsistent(t, result.Events)
counts := CountEventsByType(result.Events)
t.Logf("opencode-acp completed in %s: %v", result.Duration, counts)
}
@@ -392,7 +392,7 @@ func TestHandleUserMessage_ToolResultStillProcessed(t *testing.T) {
Type: claudecode.MessageTypeUser,
UUID: "msg-uuid-4",
Message: &claudecode.AssistantMessage{
Role: "user",
Role: "user",
Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"tool-123","content":"output"}]`),
},
}
@@ -535,7 +535,7 @@ func (b *syncBuf) Write(p []byte) (int, error) {
return len(p), nil
}
func (b *syncBuf) Len() int { return len(b.data) }
func (b *syncBuf) Len() int { return len(b.data) }
func (b *syncBuf) Bytes() []byte { return b.data }
// emptyReader always returns EOF.
@@ -597,8 +597,8 @@ func TestHandleUserMessage_SubagentToolUseResult(t *testing.T) {
// Pre-register a pending Task tool call
n := NewNormalizer()
taskPayload := n.NormalizeToolCall("Task", map[string]any{
"description": "Count lines",
"prompt": "Count lines in file",
"description": "Count lines",
"prompt": "Count lines in file",
"subagent_type": "Bash",
})
a.pendingToolCalls["task-1"] = taskPayload
@@ -170,7 +170,7 @@ func Load() *Config {
HealthCheckInterval: getEnvInt("AGENTCTL_HEALTH_CHECK_INTERVAL", 5),
ProcessBufferMaxBytes: getEnvInt64("AGENTCTL_PROCESS_BUFFER_MAX_BYTES", 2*1024*1024),
},
ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true),
ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true),
LogLevel: getEnvWithFallback("AGENTCTL_LOG_LEVEL", "KANDEV_LOG_LEVEL", "info"),
LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"),
McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""),
+3 -3
View File
@@ -38,9 +38,9 @@ type Client struct {
controlCh chan ControlEvent
// SSE connection tracking - prevents multiple concurrent connections
sseCancel context.CancelFunc
sseActive bool
lastEventID string // Last-Event-ID for reconnection
sseCancel context.CancelFunc
sseActive bool
lastEventID string // Last-Event-ID for reconnection
mu sync.RWMutex
closed bool