From 4e2e59b1ec01c75499157accd296cf1595f2b106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 21 Feb 2026 21:54:56 +0000 Subject: [PATCH] feat: opencode acp --- .../internal/agent/agents/opencode_acp.go | 81 +++++++++++++++++++ .../agent/lifecycle/manager_launch.go | 2 +- .../backend/internal/agent/lifecycle/types.go | 28 +++---- .../internal/agent/registry/registry.go | 1 + .../agentctl/server/adapter/amp_adapter.go | 2 +- .../server/adapter/e2e/auggie_test.go | 2 +- .../server/adapter/e2e/gemini_test.go | 2 +- .../server/adapter/e2e/opencode_acp_test.go | 30 +++++++ .../transport/streamjson/adapter_test.go | 8 +- .../internal/agentctl/server/config/config.go | 2 +- apps/backend/pkg/opencode/client.go | 6 +- 11 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 apps/backend/internal/agent/agents/opencode_acp.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/opencode_acp_test.go diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go new file mode 100644 index 0000000000..2c13f415e3 --- /dev/null +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -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 +} diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 67ec4a2b63..0c62092421 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -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) } diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index a8aa3b07cc..1c162632c6 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -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 diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index fab83a99d5..c79323d6b7 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -51,6 +51,7 @@ func (r *Registry) LoadDefaults() { agents.NewCopilot(), agents.NewGemini(), agents.NewOpenCode(), + agents.NewOpenCodeACP(), agents.NewAmp(), agents.NewMockAgent(), } diff --git a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go index 7915770ff3..14d2179f04 100644 --- a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go @@ -1119,6 +1119,6 @@ func (a *AmpAdapter) IsOneShot() bool { // Verify interface implementation. var ( - _ AgentAdapter = (*AmpAdapter)(nil) + _ AgentAdapter = (*AmpAdapter)(nil) _ OneShotAdapter = (*AmpAdapter)(nil) ) diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/auggie_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/auggie_test.go index 3a01305094..a188880c71 100644 --- a/apps/backend/internal/agentctl/server/adapter/e2e/auggie_test.go +++ b/apps/backend/internal/agentctl/server/adapter/e2e/auggie_test.go @@ -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{ diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/gemini_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/gemini_test.go index 38811a027d..e5fa7e12a2 100644 --- a/apps/backend/internal/agentctl/server/adapter/e2e/gemini_test.go +++ b/apps/backend/internal/agentctl/server/adapter/e2e/gemini_test.go @@ -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{ diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/opencode_acp_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/opencode_acp_test.go new file mode 100644 index 0000000000..d3882c8035 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/opencode_acp_test.go @@ -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) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go index 8106d21bf6..a1d22c30ea 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go @@ -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 diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 4a12266d4d..c287f133ea 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -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", ""), diff --git a/apps/backend/pkg/opencode/client.go b/apps/backend/pkg/opencode/client.go index c6aa580e58..4bbb0c1f1e 100644 --- a/apps/backend/pkg/opencode/client.go +++ b/apps/backend/pkg/opencode/client.go @@ -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