Adds native support for Devin CLI (Cognition, https://cli.devin.ai/) as a named cc-connect agent, alongside Claude Code / Codex / Cursor / Gemini / iFlow / Qoder / OpenCode. Users can now configure Devin with a minimal `type = "devin"` block rather than hand-rolling a generic ACP config. [[projects]] name = "my-project" [projects.agent] type = "devin" # NEW [projects.agent.options] work_dir = "/path/to/code" # command / args / display_name default to "devin" / ["acp"] / "Devin" # mode, auth_method, env (for WINDSURF_API_KEY) passed through to ACP Credentials come from the local `devin auth login` store — cc-connect never sees or forwards any token. Windsurf Enterprise users can inject WINDSURF_API_KEY via the agent env option. The new `agent/devin/` package is a thin wrapper around `agent/acp/`: Devin speaks the Agent Client Protocol over stdio via its `devin acp` subcommand, so the session/prompt, session/update, permission, and tool-call plumbing is shared. Embedding *acp.Agent and shadowing only Name() gives us identity and defaulting without duplicating transport code: type Agent struct { *acp.Agent } func (a *Agent) Name() string { return "devin" } Wiring Devin revealed two protocol features that agent/acp had left as MVP-skipped TODOs. Both are needed for `/list` and `/mode` to work in IM, so this PR fills them in as part of the Devin integration — Cursor Agent, OpenClaw, and Copilot CLI all pick up the same improvements automatically. ListSessions now spawns a short-lived `<command>` process that runs just initialize + session/list, then tears down. Capability is discovered via `agentCapabilities.sessionCapabilities.list`, and a negative result is cached on the Agent so non-supporting servers pay the spawn cost only once. Soft-degrades on JSON-RPC -32601 (method not found) and -32600 (invalid request). Measured cost against `devin acp` 2026.4.9-0: ~40 ms round trip. Agent implements core.ModeSwitcher (SetMode / GetMode / PermissionModes); the modes list is sourced from the modes block returned by session/new or session/load. GetMode precedence: the most recent explicit SetMode wins over the last server-advertised currentModeId, so `/mode plan` reports the new mode back to the user immediately rather than the cached previous one. acpSession implements core.LiveModeSwitcher via session/set_mode with client-side modeId validation against the cached availableModes. Client-side validation is mandatory because at least one ACP server (devin acp 2026.4.9-0) silently accepts unknown modeIds with an empty result — server-only validation would let typos go undetected. session/update notifications with sessionUpdate = current_mode_update are absorbed so cc-connect stays in sync when the user switches modes from another client (e.g. the Windsurf IDE). newACPSession moved from seven positional args to an acpSessionConfig struct. Private API, not a breaking change externally; the one exported consumer (cursor_integration_test.go) is updated. A sessionCallbacks interface lets running acpSession instances report handshake-derived capabilities and modes back to the parent Agent, since the engine (not the agent) owns the session lifetime. - README.md / README.zh-CN.md: Devin added to the Support Matrix and the agent count bumped from 7 to 8. - config.example.toml: dedicated Devin section placed before the generic ACP examples, with prerequisites and override notes. - web/src/pages/Projects/ProjectList.tsx: Devin added to the agent dropdown so the web admin UI surfaces it alongside other first-class agents. - Makefile: ALL_AGENTS += devin (enables selective compilation tags `no_devin` / AGENTS=devin,...). 22 new tests total (7 for agent/devin, 15 for agent/acp). All pass with -race. agent/acp wrapper tests avoid requiring `devin` in PATH by pinning `command = "true"` to satisfy the upstream exec.LookPath check while still exercising the Name() shadow and default injection. End-to-end verified against live `devin acp` 2026.4.9-0 through the cc-connect bridge: - /mode lists Devin's five modes with ▶ Code marked current - /mode plan switches and the engine reports "switched to **Plan**" - /list shows "devin Sessions (1)" (confirms Name() propagates to UI) - agent field in engine logs = "devin" (not "acp") Closes none — net-new agent support. Generated with [Devin](https://cli.devin.ai/docs) Co-authored-by: youngshook <youngshook@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
146 lines
5.3 KiB
Go
146 lines
5.3 KiB
Go
package devin
|
|
|
|
import (
|
|
"os/exec"
|
|
"testing"
|
|
|
|
"github.com/chenhg5/cc-connect/agent/acp"
|
|
)
|
|
|
|
// TestApplyDevinDefaults_FillsUnsetFields verifies the three Devin-
|
|
// specific defaults are applied when the user provides a minimal
|
|
// [projects.agent.options] block. This is the path most users hit —
|
|
// config.example.toml shows a bare `type = "devin"` section and we
|
|
// want that to just work.
|
|
func TestApplyDevinDefaults_FillsUnsetFields(t *testing.T) {
|
|
got := applyDevinDefaults(map[string]any{})
|
|
if got["command"] != "devin" {
|
|
t.Errorf("command = %v, want devin", got["command"])
|
|
}
|
|
args, ok := got["args"].([]string)
|
|
if !ok || len(args) != 1 || args[0] != "acp" {
|
|
t.Errorf("args = %v, want [acp]", got["args"])
|
|
}
|
|
if got["display_name"] != "Devin" {
|
|
t.Errorf("display_name = %v, want Devin", got["display_name"])
|
|
}
|
|
}
|
|
|
|
// TestApplyDevinDefaults_UserOptsWin ensures we never stomp on
|
|
// explicit user config. Common reason to override `command`: absolute
|
|
// path for launchd / systemd deployments where ~/.local/bin isn't on
|
|
// $PATH. Common reason to override `display_name`: running multiple
|
|
// Devin instances against different Windsurf workspaces.
|
|
func TestApplyDevinDefaults_UserOptsWin(t *testing.T) {
|
|
got := applyDevinDefaults(map[string]any{
|
|
"command": "/usr/local/bin/devin",
|
|
"args": []string{"acp", "--verbose"},
|
|
"display_name": "Devin (staging)",
|
|
})
|
|
if got["command"] != "/usr/local/bin/devin" {
|
|
t.Errorf("command was overwritten: %v", got["command"])
|
|
}
|
|
args := got["args"].([]string)
|
|
if len(args) != 2 || args[1] != "--verbose" {
|
|
t.Errorf("args were overwritten: %v", got["args"])
|
|
}
|
|
if got["display_name"] != "Devin (staging)" {
|
|
t.Errorf("display_name was overwritten: %v", got["display_name"])
|
|
}
|
|
}
|
|
|
|
// TestApplyDevinDefaults_BlankCommandGetsDefault covers a subtle TOML
|
|
// quirk: `command = ""` (explicit blank) should be treated as "use
|
|
// the default" rather than surfacing a cryptic "command is required"
|
|
// error. Matches how the rest of cc-connect treats whitespace-only
|
|
// string options.
|
|
func TestApplyDevinDefaults_BlankCommandGetsDefault(t *testing.T) {
|
|
got := applyDevinDefaults(map[string]any{"command": " "})
|
|
if got["command"] != "devin" {
|
|
t.Errorf("command = %v, want devin (blank should fall through)", got["command"])
|
|
}
|
|
}
|
|
|
|
// TestApplyDevinDefaults_NilOpts guards against nil-map panics at
|
|
// registry level. core.CreateAgent may in principle pass nil if a
|
|
// project entry has no [projects.agent.options] table at all.
|
|
func TestApplyDevinDefaults_NilOpts(t *testing.T) {
|
|
got := applyDevinDefaults(nil)
|
|
if got == nil || got["command"] != "devin" {
|
|
t.Errorf("nil opts should yield defaults, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestApplyDevinDefaults_PreservesOtherAcpOptions ensures pass-through
|
|
// of ACP-level knobs (mode, auth_method, env, work_dir) that the
|
|
// wrapper must not touch. These are handled by agent/acp.
|
|
func TestApplyDevinDefaults_PreservesOtherAcpOptions(t *testing.T) {
|
|
got := applyDevinDefaults(map[string]any{
|
|
"work_dir": "/tmp/proj",
|
|
"mode": "plan",
|
|
"auth_method": "windsurf-api-key",
|
|
"env": map[string]string{"WINDSURF_API_KEY": "wk_xxx"},
|
|
})
|
|
if got["work_dir"] != "/tmp/proj" {
|
|
t.Errorf("work_dir lost: %v", got["work_dir"])
|
|
}
|
|
if got["mode"] != "plan" {
|
|
t.Errorf("mode lost: %v", got["mode"])
|
|
}
|
|
if got["auth_method"] != "windsurf-api-key" {
|
|
t.Errorf("auth_method lost: %v", got["auth_method"])
|
|
}
|
|
if env, ok := got["env"].(map[string]string); !ok || env["WINDSURF_API_KEY"] != "wk_xxx" {
|
|
t.Errorf("env lost: %v", got["env"])
|
|
}
|
|
}
|
|
|
|
// TestNew_ReturnsDevinWrapper verifies the full New() → acp.New()
|
|
// path produces a *devin.Agent that shadows the embedded *acp.Agent's
|
|
// Name(). Uses `command: "true"` (a POSIX builtin guaranteed to be in
|
|
// PATH on both Linux and macOS, CI included) to bypass agent/acp's
|
|
// exec.LookPath check without requiring a real `devin` binary.
|
|
func TestNew_ReturnsDevinWrapper(t *testing.T) {
|
|
if _, err := exec.LookPath("true"); err != nil {
|
|
t.Skip("'true' not in PATH — unusual environment, skipping")
|
|
}
|
|
a, err := New(map[string]any{"command": "true"})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
if got := a.Name(); got != "devin" {
|
|
t.Fatalf("Name() = %q, want devin (must shadow embedded acp.Name)", got)
|
|
}
|
|
wrapper, ok := a.(*Agent)
|
|
if !ok {
|
|
t.Fatalf("New() returned %T, want *devin.Agent", a)
|
|
}
|
|
// Sanity: the embedded acp.Agent is the backing implementation.
|
|
var _ *acp.Agent = wrapper.Agent
|
|
// Display name still reflects the Devin default even when command
|
|
// was overridden to "true".
|
|
if got := wrapper.CLIDisplayName(); got != "Devin" {
|
|
t.Fatalf("CLIDisplayName() = %q, want Devin", got)
|
|
}
|
|
}
|
|
|
|
// TestNew_DisplayNameOverride locks in that a user-provided
|
|
// display_name reaches the embedded acp.Agent unchanged (relevant for
|
|
// multi-project setups where the bot's `/status` output needs to
|
|
// distinguish several concurrent Devin sessions).
|
|
func TestNew_DisplayNameOverride(t *testing.T) {
|
|
if _, err := exec.LookPath("true"); err != nil {
|
|
t.Skip("'true' not in PATH — skipping")
|
|
}
|
|
a, err := New(map[string]any{
|
|
"command": "true",
|
|
"display_name": "Devin (prod)",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
if got := a.(*Agent).CLIDisplayName(); got != "Devin (prod)" {
|
|
t.Fatalf("CLIDisplayName() = %q, want %q", got, "Devin (prod)")
|
|
}
|
|
}
|