feat: opencode acp
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user