diff --git a/apps/docs/content/docs/install-agent-runtime.mdx b/apps/docs/content/docs/install-agent-runtime.mdx index a1cd69d47d8..45de390b47f 100644 --- a/apps/docs/content/docs/install-agent-runtime.mdx +++ b/apps/docs/content/docs/install-agent-runtime.mdx @@ -181,6 +181,17 @@ Qwen Code uses its native JSONL protocol in daemon mode: `qwen -p --out | Authentication | Run `qwen` once interactively and complete its configured authentication flow before starting the daemon. | | Notes | Minimum CLI version: `0.20.0`. Override the binary with `MULTICA_QWEN_PATH` and set a daemon-wide default model with `MULTICA_QWEN_MODEL`. Add daemon-wide non-managed flags with `MULTICA_QWEN_ARGS` (POSIX shellword parsing). Managed agent `mcp_config` is written to a 0600 temporary JSON file and passed through `--mcp-config`; the file is removed after the run. Leave the field null to inherit native Qwen Code MCP settings. | +### Devin CLI (Cognition) + +Devin CLI runs through its ACP server: `devin acp`. Session resumption uses ACP `session/load`; the runtime brief is written to `AGENTS.md`; assigned skills are copied to `.devin/skills/`; and personal skills are discovered from `~/.config/devin/skills/`. Devin manages its model in its own CLI configuration, so Multica disables the per-agent model picker. + +| | | +|---|---| +| Daemon looks for | `devin` | +| Install | Install Devin CLI using Cognition's current [Devin documentation](https://docs.devin.ai/), then ensure `devin` is on `PATH`. | +| Authentication | Run `devin` once interactively and complete the configured authentication flow before starting the daemon. | +| Notes | Override the binary with `MULTICA_DEVIN_PATH`. Add non-managed arguments with `custom_args`; Multica reserves the `acp` subcommand. | + ### Trae CLI (ByteDance) ByteDance's official TRAE CLI (`traecli`, paired with the Trae IDE — **not** the open-source `bytedance/trae-agent`). It is ACP-native, so Multica drives it over stdio via `traecli acp serve --yolo`, sharing the transport with Hermes, Kimi, Kiro CLI, and Qoder. Session resumption works through ACP `session/load`, MCP config is passed through ACP `mcpServers`, model selection is discovered dynamically (switched per task via `session/set_model`), and skills are copied into `.traecli/skills/`. diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index 14c6dae27aa..6ee51ee0074 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -121,6 +121,7 @@ export const RUNTIME_PROFILE_PROTOCOL_FAMILIES = [ "traecli", "grok", "qwen", + "devin", ] as const; export type RuntimeProtocolFamily = diff --git a/packages/views/onboarding/steps/step-welcome.tsx b/packages/views/onboarding/steps/step-welcome.tsx index 572b059a14c..099a0100f11 100644 --- a/packages/views/onboarding/steps/step-welcome.tsx +++ b/packages/views/onboarding/steps/step-welcome.tsx @@ -282,7 +282,8 @@ type ProviderName = | "qoder" | "pi" | "copilot" - | "cursor"; + | "cursor" + | "devin"; type ActivityActor = | { kind: "user"; name: string; initial: string } diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx index a157e2afd9f..1e650d2f0b4 100644 --- a/packages/views/runtimes/components/provider-logo.tsx +++ b/packages/views/runtimes/components/provider-logo.tsx @@ -337,6 +337,8 @@ export function ProviderLogo({ return ; case "qwen": return ; + case "devin": + return ; default: return ; } diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 2960e4a2230..c7570f6cf1e 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -91,7 +91,7 @@ type Config struct { CLIVersion string // multica CLI version (e.g. "0.1.13") LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone Profile string // profile name (empty = default) - Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok, qwen + Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok, qwen, devin WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces) KeepEnvAfterTask bool // preserve env after task for debugging HealthPort int // local HTTP port for health checks (default: 19514) @@ -348,8 +348,11 @@ func LoadConfig(overrides Overrides) (Config, error) { if e, ok := probe("MULTICA_QWEN_PATH", "qwen", "MULTICA_QWEN_MODEL"); ok { agents["qwen"] = e } + if e, ok := probe("MULTICA_DEVIN_PATH", "devin", ""); ok { + agents["devin"] = e + } if len(agents) == 0 && !overrides.AllowNoAgents { - return Config{}, fmt.Errorf("no agent CLI found: install claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor-agent, kimi, kiro-cli, agy, qodercli, traecli, grok, or qwen and ensure it is on PATH") + return Config{}, fmt.Errorf("no agent CLI found: install claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor-agent, kimi, kiro-cli, agy, qodercli, traecli, grok, qwen, or devin and ensure it is on PATH") } claudeArgs, err := shellArgsFromEnv("MULTICA_CLAUDE_ARGS") @@ -863,7 +866,7 @@ func isExecutableFile(path string) bool { // invocation, instead of paying the cost-per-miss. var defaultAgentCommandNames = []string{ "claude", "codex", "opencode", "deveco", "openclaw", "hermes", - "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "codebuddy", "agy", "traecli", "grok", "qwen", + "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "codebuddy", "agy", "traecli", "grok", "qwen", "devin", } // codexDesktopAppBundlePaths returns candidate macOS app-bundle locations for diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 807012c8e19..b8ef8c58b15 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -3678,6 +3678,7 @@ var runtimeDisplayNameOverrides = map[string]string{ "traecli": "Trae", "grok": "Grok", "qwen": "Qwen Code", + "devin": "Devin", } // providerDisplayName returns the human-facing runtime name for a provider key. diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index b012014d03d..1d180d6914e 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -408,6 +408,8 @@ func skillsDirPath(workDir, provider string) string { // (and also scans .agents/skills/). Prefer the native .grok tree. // See Grok user-guide skills.md. return filepath.Join(workDir, ".grok", "skills") + case "devin": + return filepath.Join(workDir, ".devin", "skills") default: // Fallback: write to .agent_context/skills/ (referenced by meta config). return filepath.Join(workDir, ".agent_context", "skills") diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 09b82f7c448..14327346978 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -201,7 +201,7 @@ func runtimeConfigPath(workDir, provider string) string { return filepath.Join(workDir, "CODEBUDDY.md") case "qwen": return filepath.Join(workDir, "QWEN.md") - case "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli", "grok": + case "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli", "grok", "devin": return filepath.Join(workDir, "AGENTS.md") default: return "" diff --git a/server/internal/daemon/local_skills.go b/server/internal/daemon/local_skills.go index ffbf3185c67..8cd68e47cd5 100644 --- a/server/internal/daemon/local_skills.go +++ b/server/internal/daemon/local_skills.go @@ -189,6 +189,8 @@ func localSkillRootsForProvider(provider string) ([]localSkillRoot, bool, error) qwenHome = filepath.Join(home, ".qwen") } providerRoot = filepath.Join(qwenHome, "skills") + case "devin": + providerRoot = filepath.Join(home, ".config", "devin", "skills") default: return nil, false, nil } diff --git a/server/migrations/212_runtime_profile_add_devin.down.sql b/server/migrations/212_runtime_profile_add_devin.down.sql new file mode 100644 index 00000000000..8fb1d8cf0c1 --- /dev/null +++ b/server/migrations/212_runtime_profile_add_devin.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE runtime_profile DROP CONSTRAINT IF EXISTS runtime_profile_protocol_family_check; + +ALTER TABLE runtime_profile ADD CONSTRAINT runtime_profile_protocol_family_check + CHECK (protocol_family IN ( + 'claude', 'codebuddy', 'codex', 'copilot', 'opencode', 'openclaw', + 'hermes', 'pi', 'cursor', 'kimi', 'kiro', 'antigravity', 'qoder', + 'traecli', 'deveco', 'grok', 'qwen' + )) NOT VALID; diff --git a/server/migrations/212_runtime_profile_add_devin.up.sql b/server/migrations/212_runtime_profile_add_devin.up.sql new file mode 100644 index 00000000000..4d065bdd21a --- /dev/null +++ b/server/migrations/212_runtime_profile_add_devin.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE runtime_profile DROP CONSTRAINT IF EXISTS runtime_profile_protocol_family_check; + +ALTER TABLE runtime_profile ADD CONSTRAINT runtime_profile_protocol_family_check + CHECK (protocol_family IN ( + 'claude', 'codebuddy', 'codex', 'copilot', 'opencode', 'openclaw', + 'hermes', 'pi', 'cursor', 'kimi', 'kiro', 'antigravity', 'qoder', + 'traecli', 'deveco', 'grok', 'qwen', 'devin' + )) NOT VALID; diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index 2932d4649c4..87e365f3e8c 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -221,6 +221,7 @@ var SupportedTypes = []string{ "traecli", "grok", "qwen", + "devin", } // IsSupportedType reports whether agentType is in the SupportedTypes whitelist. @@ -302,8 +303,10 @@ func New(agentType string, cfg Config) (Backend, error) { return &grokBackend{cfg: cfg}, nil case "qwen": return &qwenBackend{cfg: cfg}, nil + case "devin": + return &devinBackend{cfg: cfg}, nil default: - return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok, qwen)", agentType) + return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok, qwen, devin)", agentType) } } @@ -336,6 +339,7 @@ var launchHeaders = map[string]string{ "traecli": "traecli acp serve", "grok": "grok agent stdio", "qwen": "qwen -p (stream-json)", + "devin": "devin acp", } // LaunchHeader returns the user-visible launch skeleton for agentType, or an diff --git a/server/pkg/agent/agent_supported_types_test.go b/server/pkg/agent/agent_supported_types_test.go index 6ca5d4184ad..ee962683015 100644 --- a/server/pkg/agent/agent_supported_types_test.go +++ b/server/pkg/agent/agent_supported_types_test.go @@ -40,7 +40,7 @@ func TestSupportedTypesMatchesMigrationWhitelist(t *testing.T) { "claude": true, "codebuddy": true, "codex": true, "copilot": true, "opencode": true, "deveco": true, "openclaw": true, "hermes": true, "pi": true, "cursor": true, "kimi": true, "kiro": true, "antigravity": true, - "qoder": true, "traecli": true, "grok": true, "qwen": true, + "qoder": true, "traecli": true, "grok": true, "qwen": true, "devin": true, } if len(SupportedTypes) != len(want) { t.Fatalf("SupportedTypes has %d entries, migration whitelist has %d; keep them in lockstep", len(SupportedTypes), len(want)) diff --git a/server/pkg/agent/devin.go b/server/pkg/agent/devin.go new file mode 100644 index 00000000000..1953eaa677a --- /dev/null +++ b/server/pkg/agent/devin.go @@ -0,0 +1,358 @@ +package agent + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "sync/atomic" + "time" +) + +// devinBlockedArgs are flags hardcoded by the daemon that must not be +// overridden by user-configured custom_args. `acp` is the protocol +// subcommand that drives the ACP JSON-RPC transport for Devin for +// Terminal; overriding it would break the daemon ↔ Devin communication +// contract. `--agent-type` is exposed to users so they can opt into +// Devin's specialized agents (e.g. summarizer). +var devinBlockedArgs = map[string]blockedArgMode{ + "acp": blockedStandalone, +} + +// devinBackend implements Backend by spawning `devin acp` and communicating +// via the standard ACP (Agent Client Protocol) JSON-RPC 2.0 transport over +// stdin/stdout. +// +// Devin for Terminal (https://docs.devin.ai/) ships an ACP server out of the +// box via the `devin acp` subcommand. We reuse the existing hermesClient ACP +// transport since Devin's wire format matches the protocol Hermes / Kimi / +// Kiro already speak — only the binary, env, and tool-name extraction differ. +// +// Notes on Devin's ACP dialect (verified against devin 2026.4.29-0): +// +// - agentCapabilities.loadSession is true, so session/load drives resume. +// - Devin emits session/update notifications with the standard ACP +// `sessionUpdate` field; hermesClient.normalizeACPUpdate handles this +// shape natively. +// - Devin does NOT implement session/set_model. Model selection happens +// via Devin's own `config_option_update` mechanism (driven by Devin's +// UI / config file). When opts.Model is set, we surface a warning in +// the daemon log and continue with Devin's default model rather than +// failing the task. +// - Devin's `acp` subcommand does NOT accept the root-level +// --permission-mode flag, so daemon-mode auto-approval is handled +// entirely by hermesClient.handleAgentRequest replying +// "approve_for_session" to every session/request_permission request, +// identical to how kimi / kiro are driven. +type devinBackend struct { + cfg Config +} + +func (b *devinBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { + execPath := b.cfg.ExecutablePath + if execPath == "" { + execPath = "devin" + } + if _, err := exec.LookPath(execPath); err != nil { + return nil, fmt.Errorf("devin executable not found at %q: %w", execPath, err) + } + + timeout := opts.Timeout + if timeout == 0 { + timeout = 20 * time.Minute + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + + devinArgs := append([]string{"acp"}, filterCustomArgs(opts.CustomArgs, devinBlockedArgs, b.cfg.Logger)...) + cmd := exec.CommandContext(runCtx, execPath, devinArgs...) + hideAgentWindow(cmd) + b.cfg.Logger.Info("agent command", "exec", execPath, "args", devinArgs) + if opts.Cwd != "" { + cmd.Dir = opts.Cwd + } + cmd.Env = buildEnv(b.cfg.Env) + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("devin stdout pipe: %w", err) + } + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("devin stdin pipe: %w", err) + } + // Forward stderr to the daemon log *and* sniff provider-level + // errors out of it so they surface in the task result instead of + // being lost as a misleading "empty output" failure. + providerErr := newACPProviderErrorSniffer("devin") + cmd.Stderr = io.MultiWriter(newLogWriter(b.cfg.Logger, "[devin:stderr] "), providerErr) + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start devin: %w", err) + } + + b.cfg.Logger.Info("devin acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) + + msgCh := make(chan Message, 256) + resCh := make(chan Result, 1) + + var outputMu sync.Mutex + var output strings.Builder + var streamingCurrentTurn atomic.Bool + + promptDone := make(chan hermesPromptResult, 1) + + c := &hermesClient{ + cfg: b.cfg, + stdin: stdin, + pending: make(map[int]*pendingRPC), + pendingTools: make(map[string]*pendingToolCall), + acceptNotification: func(string) bool { + return streamingCurrentTurn.Load() + }, + onMessage: func(msg Message) { + if !streamingCurrentTurn.Load() { + return + } + if msg.Type == MessageToolUse { + msg.Tool = devinToolNameFromTitle(msg.Tool) + } + if msg.Type == MessageText { + outputMu.Lock() + output.WriteString(msg.Content) + outputMu.Unlock() + } + trySend(msgCh, msg) + }, + onPromptDone: func(result hermesPromptResult) { + if !streamingCurrentTurn.Load() { + return + } + select { + case promptDone <- result: + default: + } + }, + } + + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + c.handleLine(line) + } + c.closeAllPending(fmt.Errorf("devin process exited")) + }() + + go func() { + defer cancel() + defer close(msgCh) + defer close(resCh) + defer func() { + stdin.Close() + _ = cmd.Wait() + }() + + startTime := time.Now() + finalStatus := "completed" + var finalError string + var sessionID string + + _, err := c.request(runCtx, "initialize", map[string]any{ + "protocolVersion": 1, + "clientInfo": map[string]any{ + "name": "multica-agent-sdk", + "version": "0.2.0", + }, + "clientCapabilities": map[string]any{}, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("devin initialize failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + + cwd := opts.Cwd + if cwd == "" { + cwd = "." + } + + if opts.ResumeSessionID != "" { + _, err := c.request(runCtx, "session/load", map[string]any{ + "cwd": cwd, + "sessionId": opts.ResumeSessionID, + "mcpServers": []any{}, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("devin session/load failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + sessionID = opts.ResumeSessionID + } else { + result, err := c.request(runCtx, "session/new", map[string]any{ + "cwd": cwd, + "mcpServers": []any{}, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("devin session/new failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + sessionID = extractACPSessionID(result) + if sessionID == "" { + finalStatus = "failed" + finalError = "devin session/new returned no session ID" + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + } + + c.sessionID = sessionID + b.cfg.Logger.Info("devin session created", "session_id", sessionID) + + // Devin doesn't implement session/set_model — model selection + // is driven by Devin's own config_option_update mechanism. Log + // a warning so the requested model isn't silently ignored, but + // keep the task running with Devin's default model. + if opts.Model != "" { + b.cfg.Logger.Warn("devin does not support session/set_model; falling back to Devin's default model", "requested_model", opts.Model) + } + + userText := prompt + if opts.SystemPrompt != "" { + userText = opts.SystemPrompt + "\n\n---\n\n" + prompt + } + + promptBlocks := []map[string]any{ + {"type": "text", "text": userText}, + } + streamingCurrentTurn.Store(true) + _, err = c.request(runCtx, "session/prompt", map[string]any{ + "sessionId": sessionID, + "prompt": promptBlocks, + }) + if err != nil { + if runCtx.Err() == context.DeadlineExceeded { + finalStatus = "timeout" + finalError = fmt.Sprintf("devin timed out after %s", timeout) + } else if runCtx.Err() == context.Canceled { + finalStatus = "aborted" + finalError = "execution cancelled" + } else { + finalStatus = "failed" + finalError = fmt.Sprintf("devin session/prompt failed: %v", err) + } + } else { + select { + case pr := <-promptDone: + if pr.stopReason == "cancelled" { + finalStatus = "aborted" + finalError = "devin cancelled the prompt" + } + c.usageMu.Lock() + c.usage.InputTokens += pr.usage.InputTokens + c.usage.OutputTokens += pr.usage.OutputTokens + c.usageMu.Unlock() + default: + } + } + + duration := time.Since(startTime) + b.cfg.Logger.Info("devin finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + + stdin.Close() + cancel() + + <-readerDone + + outputMu.Lock() + finalOutput := output.String() + outputMu.Unlock() + + if finalStatus == "completed" && finalOutput == "" { + if msg := providerErr.message(); msg != "" { + finalStatus = "failed" + finalError = msg + } + } + + c.usageMu.Lock() + u := c.usage + c.usageMu.Unlock() + + var usageMap map[string]TokenUsage + if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 { + model := opts.Model + if model == "" { + model = "unknown" + } + usageMap = map[string]TokenUsage{model: u} + } + + resCh <- Result{ + Status: finalStatus, + Output: finalOutput, + Error: finalError, + DurationMs: duration.Milliseconds(), + SessionID: sessionID, + Usage: usageMap, + } + }() + + return &Session{Messages: msgCh, Result: resCh}, nil +} + +// devinToolNameFromTitle normalizes Devin's ACP tool titles into the +// canonical snake_case identifiers Multica's UI expects. Devin's tool +// naming follows Devin for Terminal's documented tool set (read, write, +// edit, exec, grep, find, etc.), so the mapping is largely a passthrough +// with a few aliases for cosmetic title variations. +func devinToolNameFromTitle(title string) string { + t := strings.TrimSpace(title) + if t == "" { + return "" + } + + if idx := strings.Index(t, ":"); idx > 0 { + t = strings.TrimSpace(t[:idx]) + } + + lower := strings.ToLower(t) + switch lower { + case "read", "read file", "view": + return "read_file" + case "write", "write file", "create": + return "write_file" + case "edit", "patch", "apply", "replace": + return "edit_file" + case "exec", "shell", "bash", "terminal", "run", "run command", "run shell command": + return "terminal" + case "grep", "search", "search files", "find": + return "search_files" + case "glob", "find files": + return "glob" + case "fetch", "web fetch", "webfetch": + return "web_fetch" + case "web search", "websearch": + return "web_search" + case "todo", "todo write", "todo list", "todo_list": + return "todo_write" + } + + return strings.ReplaceAll(lower, " ", "_") +} diff --git a/server/pkg/agent/devin_test.go b/server/pkg/agent/devin_test.go new file mode 100644 index 00000000000..845b1b1d75f --- /dev/null +++ b/server/pkg/agent/devin_test.go @@ -0,0 +1,356 @@ +package agent + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNewReturnsDevinBackend(t *testing.T) { + t.Parallel() + b, err := New("devin", Config{ExecutablePath: "/nonexistent/devin"}) + if err != nil { + t.Fatalf("New(devin) error: %v", err) + } + if _, ok := b.(*devinBackend); !ok { + t.Fatalf("expected *devinBackend, got %T", b) + } +} + +func TestDevinToolNameFromTitle(t *testing.T) { + t.Parallel() + tests := []struct { + title string + want string + }{ + {"Read file: /tmp/foo.go", "read_file"}, + {"View: /tmp/foo.go", "read_file"}, + {"Write: /tmp/bar.go", "write_file"}, + {"Edit: /tmp/x", "edit_file"}, + {"Patch: /tmp/x", "edit_file"}, + {"Replace: /tmp/x", "edit_file"}, + {"Exec: ls -la", "terminal"}, + {"Shell: ls -la", "terminal"}, + {"Run command: pwd", "terminal"}, + {"grep", "search_files"}, + {"Find: foo", "search_files"}, + {"Glob: *.go", "glob"}, + {"Find files: pattern", "glob"}, + {"Web fetch: https://example.com", "web_fetch"}, + {"Web search: golang", "web_search"}, + {"Todo Write", "todo_write"}, + {"Todo List", "todo_write"}, + {"Custom Thing", "custom_thing"}, + {"", ""}, + } + for _, tt := range tests { + got := devinToolNameFromTitle(tt.title) + if got != tt.want { + t.Errorf("devinToolNameFromTitle(%q) = %q, want %q", tt.title, got, tt.want) + } + } +} + +// fakeDevinACPScript mimics `devin acp`. It captures argv (when +// DEVIN_ARGS_FILE is set) and incoming JSON-RPC requests (when +// DEVIN_REQUESTS_FILE is set), then replies with canned ACP responses +// modeled on what the real `devin 2026.4.29-0` server emits — a session +// id slug rather than a UUID, agentCapabilities.loadSession=true, and no +// `models` field on session/new (Devin streams those via +// config_option_update notifications, which Multica ignores). +func fakeDevinACPScript() string { + return `#!/bin/sh +if [ -n "$DEVIN_ARGS_FILE" ]; then + for arg in "$@"; do + printf '%s\n' "$arg" >> "$DEVIN_ARGS_FILE" + done +fi +while IFS= read -r line; do + if [ -n "$DEVIN_REQUESTS_FILE" ]; then + printf '%s\n' "$line" >> "$DEVIN_REQUESTS_FILE" + fi + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true}},"agentInfo":{"name":"affogato","title":"Affogato Agent","version":"0.0.0-dev"}}}\n' "$id" + ;; + *'"method":"session/new"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"tricolor-diver"}}\n' "$id" + ;; + *'"method":"session/load"'*) + printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"loaded-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"history should be ignored"}}}}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" + ;; + *'"method":"session/set_model"'*) + # Devin does not implement session/set_model. Reply with a JSON-RPC + # method-not-found error so any accidental call is loud. + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"Method not found"}}\n' "$id" + ;; + *'"method":"session/prompt"'*) + printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"tricolor-diver","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello from devin"}}}}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":7,"outputTokens":3}}}\n' "$id" + exit 0 + ;; + esac +done +` +} + +func TestDevinBackendSpawnsACPSubcommand(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + argsFile := filepath.Join(tempDir, "argv.txt") + fakePath := filepath.Join(tempDir, "devin") + writeTestExecutable(t, fakePath, []byte(fakeDevinACPScript())) + + backend, err := New("devin", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"DEVIN_ARGS_FILE": argsFile}, + }) + if err != nil { + t.Fatalf("new devin backend: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "do something", ExecOptions{ + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + <-session.Result + + raw, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args file: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(lines) == 0 || lines[0] != "acp" { + t.Fatalf("expected first arg to be %q, got %q", "acp", lines) + } +} + +func TestDevinBackendFiltersBlockedArgs(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + argsFile := filepath.Join(tempDir, "argv.txt") + fakePath := filepath.Join(tempDir, "devin") + writeTestExecutable(t, fakePath, []byte(fakeDevinACPScript())) + + backend, err := New("devin", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"DEVIN_ARGS_FILE": argsFile}, + }) + if err != nil { + t.Fatalf("new devin backend: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "do something", ExecOptions{ + Timeout: 5 * time.Second, + // User tries to inject a duplicate `acp` subcommand and + // keeps an unrelated --agent-type flag — only `acp` should + // be filtered, the agent type must pass through so users + // can opt into Devin's specialised agents. + CustomArgs: []string{"acp", "--agent-type", "summarizer"}, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + <-session.Result + + raw, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args file: %v", err) + } + got := strings.Join(strings.Split(strings.TrimSpace(string(raw)), "\n"), " ") + want := "acp --agent-type summarizer" + if got != want { + t.Fatalf("argv = %q, want %q", got, want) + } +} + +func TestDevinBackendSurfacesPromptOutput(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + fakePath := filepath.Join(tempDir, "devin") + writeTestExecutable(t, fakePath, []byte(fakeDevinACPScript())) + + backend, err := New("devin", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + }) + if err != nil { + t.Fatalf("new devin backend: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "say hi", ExecOptions{ + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + var messages []Message + messagesDone := make(chan struct{}) + go func() { + defer close(messagesDone) + for msg := range session.Messages { + messages = append(messages, msg) + } + }() + + result := <-session.Result + <-messagesDone + + if result.Status != "completed" { + t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error) + } + if result.Output != "hello from devin" { + t.Fatalf("output = %q, want %q", result.Output, "hello from devin") + } + if result.SessionID != "tricolor-diver" { + t.Fatalf("session id = %q, want %q", result.SessionID, "tricolor-diver") + } + if usage := result.Usage["unknown"]; usage.InputTokens != 7 || usage.OutputTokens != 3 { + t.Fatalf("usage = %+v, want input=7 output=3", usage) + } + // History notifications fire before streamingCurrentTurn flips to + // true, so the only text message we should see is the current turn's. + if len(messages) != 1 { + t.Fatalf("messages = %+v, want exactly 1 (current turn text only)", messages) + } + if messages[0].Type != MessageText || messages[0].Content != "hello from devin" { + t.Fatalf("messages[0] = %+v, want MessageText 'hello from devin'", messages[0]) + } +} + +func TestDevinBackendModelOptionLogsWarningWithoutFailing(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + requestsFile := filepath.Join(tempDir, "requests.jsonl") + fakePath := filepath.Join(tempDir, "devin") + writeTestExecutable(t, fakePath, []byte(fakeDevinACPScript())) + + backend, err := New("devin", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"DEVIN_REQUESTS_FILE": requestsFile}, + }) + if err != nil { + t.Fatalf("new devin backend: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "say hi", ExecOptions{ + Model: "claude-opus-4-7-medium", + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + result := <-session.Result + + if result.Status != "completed" { + t.Fatalf("expected completed (model-not-supported is a warning, not a failure), got %q error=%q", result.Status, result.Error) + } + + raw, err := os.ReadFile(requestsFile) + if err != nil { + t.Fatalf("read requests file: %v", err) + } + if strings.Contains(string(raw), `"method":"session/set_model"`) { + t.Fatalf("devin backend must not call session/set_model (Devin does not implement it):\n%s", raw) + } +} + +func TestDevinBackendUsesSessionLoadForResume(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + requestsFile := filepath.Join(tempDir, "requests.jsonl") + fakePath := filepath.Join(tempDir, "devin") + writeTestExecutable(t, fakePath, []byte(fakeDevinACPScript())) + + backend, err := New("devin", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"DEVIN_REQUESTS_FILE": requestsFile}, + }) + if err != nil { + t.Fatalf("new devin backend: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "continue", ExecOptions{ + ResumeSessionID: "ses_existing", + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + result := <-session.Result + + if result.Status != "completed" { + t.Fatalf("expected completed result, got status=%q error=%q", result.Status, result.Error) + } + if result.SessionID != "ses_existing" { + t.Fatalf("session id = %q, want ses_existing", result.SessionID) + } + + requests := string(mustReadFile(t, requestsFile)) + if !strings.Contains(requests, `"method":"session/load"`) { + t.Fatalf("expected session/load request, got:\n%s", requests) + } + if strings.Contains(requests, `"method":"session/new"`) { + t.Fatalf("devin backend must not call session/new on resume, got:\n%s", requests) + } + if !strings.Contains(requests, `"sessionId":"ses_existing"`) { + t.Fatalf("session/load must carry the resume id, got:\n%s", requests) + } +} + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return b +} diff --git a/server/pkg/agent/models.go b/server/pkg/agent/models.go index f4d4ac99da9..ddef4bdc568 100644 --- a/server/pkg/agent/models.go +++ b/server/pkg/agent/models.go @@ -170,6 +170,11 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod // empty list keeps the runtime default and manual model entry available // without advertising a Token-Plan-specific model to other accounts. return []Model{}, nil + case "devin": + // Devin manages model selection through its own configuration and does + // not implement the ACP session/set_model RPC. An empty catalog keeps + // the runtime picker available without advertising unsupported models. + return []Model{}, nil case "grok": // xAI Grok Build is ACP-native (`grok agent stdio`); model catalog // comes from session/new. Falls back to a small static list so the @@ -188,14 +193,15 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod // `session/set_model` RPC before each prompt; Claude / Codex / Cursor / // Gemini / Copilot / Kimi / Kiro / OpenCode / OpenClaw / Pi / Antigravity // pass it via flag or session config (Antigravity gained `--model` in agy -// 1.0.6 — MUL-3125). +// 1.0.6 — MUL-3125). Devin is the exception: its ACP server does not expose +// session/set_model, so its model is managed by the Devin CLI. // // The hook is retained — rather than inlining `true` at the call sites — so // a future model-less runtime can opt out in one place, which makes the UI // render a disabled "Managed by runtime" picker instead of an empty // dropdown plus a silently-ignored manual-entry field. func ModelSelectionSupported(providerType string) bool { - return true + return providerType != "devin" } // ModelKnownIncompatibleWithProvider reports whether a saved model is a known diff --git a/server/pkg/agent/models_test.go b/server/pkg/agent/models_test.go index 49cbc6c901a..40040e5fb24 100644 --- a/server/pkg/agent/models_test.go +++ b/server/pkg/agent/models_test.go @@ -42,6 +42,19 @@ func TestListModelsQwenUsesRuntimeDefaultAndManualEntry(t *testing.T) { } } +func TestListModelsDevinUsesRuntimeManagedModel(t *testing.T) { + got, err := ListModels(context.Background(), "devin", "") + if err != nil { + t.Fatalf("ListModels(devin) error: %v", err) + } + if len(got) != 0 { + t.Fatalf("ListModels(devin) = %+v, want no runtime-managed catalog", got) + } + if ModelSelectionSupported("devin") { + t.Fatal("devin must not advertise model selection without session/set_model support") + } +} + func TestListModelsCopilotFallsBackToStatic(t *testing.T) { // Copilot uses dynamic ACP discovery, but with no `copilot` // binary on PATH (the discovery LookPath fails) it must fall