mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
8 Commits
codex/agen
...
pr-3186-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e66be7cbb1 | ||
|
|
d498fac054 | ||
|
|
1e5deb46fa | ||
|
|
5c340d5f6d | ||
|
|
eedb570c91 | ||
|
|
f5562c6289 | ||
|
|
d2aef41fbf | ||
|
|
160a62ca9f |
@@ -61,7 +61,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, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro, antigravity
|
||||
Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro, antigravity
|
||||
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)
|
||||
@@ -81,6 +81,7 @@ type Config struct {
|
||||
AgentIdleWatchdog time.Duration // force-stop a run when the backend goes silent this long with an empty queue (0 = disabled)
|
||||
ClaudeArgs []string
|
||||
CodexArgs []string
|
||||
CodebuddyArgs []string
|
||||
}
|
||||
|
||||
// Overrides allows CLI flags to override environment variables and defaults.
|
||||
@@ -220,8 +221,11 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
if e, ok := probe("MULTICA_ANTIGRAVITY_PATH", "agy", ""); ok {
|
||||
agents["antigravity"] = e
|
||||
}
|
||||
if e, ok := probe("MULTICA_CODEBUDDY_PATH", "codebuddy", "MULTICA_CODEBUDDY_MODEL"); ok {
|
||||
agents["codebuddy"] = e
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, kimi, kiro-cli, or agy and ensure it is on PATH")
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, kimi, kiro-cli, or agy and ensure it is on PATH")
|
||||
}
|
||||
|
||||
claudeArgs, err := shellArgsFromEnv("MULTICA_CLAUDE_ARGS")
|
||||
@@ -232,6 +236,10 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
codebuddyArgs, err := shellArgsFromEnv("MULTICA_CODEBUDDY_ARGS")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
// Host info
|
||||
host, err := os.Hostname()
|
||||
@@ -430,6 +438,7 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
AgentIdleWatchdog: agentIdleWatchdog,
|
||||
ClaudeArgs: claudeArgs,
|
||||
CodexArgs: codexArgs,
|
||||
CodebuddyArgs: codebuddyArgs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -556,7 +565,7 @@ func shellArgsFromEnv(name string) ([]string, error) {
|
||||
// invocation, instead of paying the cost-per-miss.
|
||||
var defaultAgentCommandNames = []string{
|
||||
"claude", "codex", "opencode", "openclaw", "hermes",
|
||||
"gemini", "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "agy",
|
||||
"gemini", "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "agy", "codebuddy",
|
||||
}
|
||||
|
||||
var codexDesktopAppBundlePaths = func() []string {
|
||||
|
||||
@@ -3546,6 +3546,8 @@ func defaultArgsForProvider(cfg Config, provider string) []string {
|
||||
args = cfg.ClaudeArgs
|
||||
case "codex":
|
||||
args = cfg.CodexArgs
|
||||
case "codebuddy":
|
||||
args = cfg.CodebuddyArgs
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package agent provides a unified interface for executing prompts via
|
||||
// coding agents (Claude Code, Codex, Copilot, OpenCode, OpenClaw, Hermes,
|
||||
// coding agents (Claude Code, CodeBuddy, Codex, Copilot, OpenCode, OpenClaw, Hermes,
|
||||
// Gemini, Pi, Cursor, Kimi, Kiro, Antigravity). It mirrors the happy-cli
|
||||
// AgentBackend pattern, translated to idiomatic Go.
|
||||
package agent
|
||||
@@ -101,13 +101,13 @@ type Result struct {
|
||||
|
||||
// Config configures a Backend instance.
|
||||
type Config struct {
|
||||
ExecutablePath string // path to CLI binary (claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro-cli, agy)
|
||||
ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro-cli, agy)
|
||||
Env map[string]string // extra environment variables
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Backend for the given agent type.
|
||||
// Supported types: "claude", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi", "kiro", "antigravity".
|
||||
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi", "kiro", "antigravity".
|
||||
func New(agentType string, cfg Config) (Backend, error) {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
@@ -116,6 +116,8 @@ func New(agentType string, cfg Config) (Backend, error) {
|
||||
switch agentType {
|
||||
case "claude":
|
||||
return &claudeBackend{cfg: cfg}, nil
|
||||
case "codebuddy":
|
||||
return &codebuddyBackend{cfg: cfg}, nil
|
||||
case "codex":
|
||||
return &codexBackend{cfg: cfg}, nil
|
||||
case "copilot":
|
||||
@@ -139,7 +141,7 @@ func New(agentType string, cfg Config) (Backend, error) {
|
||||
case "antigravity":
|
||||
return &antigravityBackend{cfg: cfg}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro, antigravity)", agentType)
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro, antigravity)", agentType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +159,7 @@ func DetectVersion(ctx context.Context, executablePath string) (string, error) {
|
||||
var launchHeaders = map[string]string{
|
||||
"antigravity": "agy -p (print mode)",
|
||||
"claude": "claude (stream-json)",
|
||||
"codebuddy": "codebuddy (stream-json)",
|
||||
"codex": "codex app-server",
|
||||
"copilot": "copilot (json)",
|
||||
"cursor": "cursor-agent (stream-json)",
|
||||
|
||||
@@ -27,6 +27,17 @@ func TestNewReturnsCodexBackend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReturnsCodebuddyBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := New("codebuddy", Config{ExecutablePath: "/nonexistent/codebuddy"})
|
||||
if err != nil {
|
||||
t.Fatalf("New(codebuddy) error: %v", err)
|
||||
}
|
||||
if _, ok := b.(*codebuddyBackend); !ok {
|
||||
t.Fatalf("expected *codebuddyBackend, got %T", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReturnsCopilotBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := New("copilot", Config{ExecutablePath: "/nonexistent/copilot"})
|
||||
@@ -82,7 +93,7 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
|
||||
// runtime the daemon actually spawns. If a new backend is added, add an
|
||||
// entry to launchHeaders in agent.go and extend this list.
|
||||
supported := []string{
|
||||
"antigravity", "claude", "codex", "copilot", "cursor", "gemini",
|
||||
"antigravity", "claude", "codebuddy", "codex", "copilot", "cursor", "gemini",
|
||||
"hermes", "kimi", "kiro", "openclaw", "opencode", "pi",
|
||||
}
|
||||
for _, t_ := range supported {
|
||||
|
||||
454
server/pkg/agent/codebuddy.go
Normal file
454
server/pkg/agent/codebuddy.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// codebuddyBackend implements Backend by spawning the Claude Code CLI
|
||||
// (codebuddy fork) with --output-format stream-json.
|
||||
type codebuddyBackend struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// codebuddyBlockedArgs are flags hardcoded by the daemon that must not be
|
||||
// overridden by user-configured custom_args. Overriding these would break
|
||||
// the daemon↔codebuddy communication protocol.
|
||||
var codebuddyBlockedArgs = map[string]blockedArgMode{
|
||||
"-p": blockedStandalone, // non-interactive mode
|
||||
"--output-format": blockedWithValue, // stream-json protocol
|
||||
"--input-format": blockedWithValue, // stream-json protocol
|
||||
"--permission-mode": blockedWithValue, // bypassPermissions for autonomous operation
|
||||
"--mcp-config": blockedWithValue, // set by daemon from agent.mcp_config
|
||||
// `--effort` is owned by the per-agent thinking_level picker so a
|
||||
// user-supplied custom_arg cannot silently outvote it.
|
||||
"--effort": blockedWithValue,
|
||||
}
|
||||
|
||||
func buildCodebuddyArgs(opts ExecOptions, logger *slog.Logger) []string {
|
||||
args := []string{
|
||||
"-p",
|
||||
"--output-format", "stream-json",
|
||||
"--input-format", "stream-json",
|
||||
"--verbose",
|
||||
"--strict-mcp-config",
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--disallowedTools", "AskUserQuestion",
|
||||
}
|
||||
if opts.Model != "" {
|
||||
args = append(args, "--model", opts.Model)
|
||||
}
|
||||
if opts.ThinkingLevel != "" {
|
||||
args = append(args, "--effort", opts.ThinkingLevel)
|
||||
}
|
||||
if opts.MaxTurns > 0 {
|
||||
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
|
||||
}
|
||||
if opts.SystemPrompt != "" {
|
||||
args = append(args, "--append-system-prompt", opts.SystemPrompt)
|
||||
}
|
||||
if opts.ResumeSessionID != "" {
|
||||
args = append(args, "--resume", opts.ResumeSessionID)
|
||||
}
|
||||
args = append(args, filterCustomArgs(opts.ExtraArgs, codebuddyBlockedArgs, logger)...)
|
||||
args = append(args, filterCustomArgs(opts.CustomArgs, codebuddyBlockedArgs, logger)...)
|
||||
return args
|
||||
}
|
||||
|
||||
func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
||||
execPath := b.cfg.ExecutablePath
|
||||
if execPath == "" {
|
||||
execPath = "codebuddy"
|
||||
}
|
||||
if _, err := exec.LookPath(execPath); err != nil {
|
||||
return nil, fmt.Errorf("codebuddy executable not found at %q: %w", execPath, err)
|
||||
}
|
||||
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Minute
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
args := buildCodebuddyArgs(opts, b.cfg.Logger)
|
||||
|
||||
// If the caller provided an MCP config, write it to a temp file and pass
|
||||
// --mcp-config <path> so the agent uses a controlled set of MCP servers.
|
||||
var mcpConfigPath string
|
||||
var mcpFileCleanup func()
|
||||
if len(opts.McpConfig) > 0 {
|
||||
path, err := writeMcpConfigToTemp(opts.McpConfig)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
mcpConfigPath = path
|
||||
mcpFileCleanup = func() { os.Remove(mcpConfigPath) }
|
||||
args = append(args, "--mcp-config", mcpConfigPath)
|
||||
}
|
||||
// Clean up the temp file if we return before the goroutine takes ownership.
|
||||
defer func() {
|
||||
if mcpFileCleanup != nil {
|
||||
mcpFileCleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
hideAgentWindow(cmd)
|
||||
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
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("codebuddy stdout pipe: %w", err)
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("codebuddy stdin pipe: %w", err)
|
||||
}
|
||||
closeStdin := func() {
|
||||
if stdin != nil {
|
||||
_ = stdin.Close()
|
||||
stdin = nil
|
||||
}
|
||||
}
|
||||
|
||||
stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[codebuddy:stderr] "), agentStderrTailBytes)
|
||||
cmd.Stderr = stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
closeStdin()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start codebuddy: %w", err)
|
||||
}
|
||||
if err := writeCodebuddyInput(stdin, prompt); err != nil {
|
||||
closeStdin()
|
||||
cancel()
|
||||
_ = cmd.Wait()
|
||||
return nil, errors.New(withAgentStderr(fmt.Sprintf("write codebuddy input: %v", err), "codebuddy", stderrBuf.Tail()))
|
||||
}
|
||||
closeStdin()
|
||||
|
||||
b.cfg.Logger.Info("codebuddy started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
||||
|
||||
// cmd.Start() succeeded — transfer temp file ownership to the goroutine.
|
||||
mcpFileCleanup = nil
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
defer close(resCh)
|
||||
if mcpConfigPath != "" {
|
||||
defer os.Remove(mcpConfigPath)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
var output strings.Builder
|
||||
var sessionID string
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
usage := make(map[string]TokenUsage)
|
||||
|
||||
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
|
||||
go func() {
|
||||
<-runCtx.Done()
|
||||
_ = stdout.Close()
|
||||
}()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var msg codebuddySDKMessage
|
||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "assistant":
|
||||
b.handleAssistant(msg, msgCh, &output, usage)
|
||||
case "user":
|
||||
b.handleUser(msg, msgCh)
|
||||
case "system":
|
||||
if msg.SessionID != "" {
|
||||
sessionID = msg.SessionID
|
||||
}
|
||||
trySend(msgCh, Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
|
||||
case "result":
|
||||
closeStdin()
|
||||
sessionID = msg.SessionID
|
||||
if msg.ResultText != "" {
|
||||
output.Reset()
|
||||
output.WriteString(msg.ResultText)
|
||||
}
|
||||
if resultUsage := codebuddyResultUsage(msg, opts.Model); len(resultUsage) > 0 {
|
||||
usage = resultUsage
|
||||
}
|
||||
if msg.IsError {
|
||||
finalStatus = "failed"
|
||||
finalError = msg.ResultText
|
||||
}
|
||||
case "log":
|
||||
if msg.Log != nil {
|
||||
trySend(msgCh, Message{
|
||||
Type: MessageLog,
|
||||
Level: msg.Log.Level,
|
||||
Content: msg.Log.Message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process exit.
|
||||
exitErr := cmd.Wait()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
finalStatus = "timeout"
|
||||
finalError = fmt.Sprintf("codebuddy timed out after %s", timeout)
|
||||
} else if runCtx.Err() == context.Canceled {
|
||||
finalStatus = "aborted"
|
||||
finalError = "execution cancelled"
|
||||
} else if exitErr != nil && finalStatus == "completed" {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("codebuddy exited with error: %v", exitErr)
|
||||
}
|
||||
|
||||
if finalError != "" {
|
||||
finalError = withAgentStderr(finalError, "codebuddy", stderrBuf.Tail())
|
||||
}
|
||||
|
||||
b.cfg.Logger.Info("codebuddy finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
||||
|
||||
reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed")
|
||||
if reportedSessionID != sessionID {
|
||||
b.cfg.Logger.Info("codebuddy resume did not land; clearing fresh session id for daemon fallback",
|
||||
"requested_resume", opts.ResumeSessionID,
|
||||
"emitted_session", sessionID,
|
||||
)
|
||||
}
|
||||
|
||||
resCh <- Result{
|
||||
Status: finalStatus,
|
||||
Output: output.String(),
|
||||
Error: finalError,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
SessionID: reportedSessionID,
|
||||
Usage: usage,
|
||||
}
|
||||
}()
|
||||
|
||||
return &Session{Messages: msgCh, Result: resCh}, nil
|
||||
}
|
||||
|
||||
func (b *codebuddyBackend) handleAssistant(msg codebuddySDKMessage, ch chan<- Message, output *strings.Builder, usage map[string]TokenUsage) {
|
||||
var content codebuddyMessageContent
|
||||
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Accumulate token usage per model.
|
||||
if content.Usage != nil && content.Model != "" {
|
||||
u := usage[content.Model]
|
||||
u.InputTokens += content.Usage.InputTokens
|
||||
u.OutputTokens += content.Usage.OutputTokens
|
||||
u.CacheReadTokens += content.Usage.CacheReadInputTokens
|
||||
u.CacheWriteTokens += content.Usage.CacheCreationInputTokens
|
||||
usage[content.Model] = u
|
||||
}
|
||||
|
||||
for _, block := range content.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
if block.Text != "" {
|
||||
output.WriteString(block.Text)
|
||||
trySend(ch, Message{Type: MessageText, Content: block.Text})
|
||||
}
|
||||
case "thinking":
|
||||
if block.Text != "" {
|
||||
trySend(ch, Message{Type: MessageThinking, Content: block.Text})
|
||||
}
|
||||
case "tool_use":
|
||||
var input map[string]any
|
||||
if block.Input != nil {
|
||||
_ = json.Unmarshal(block.Input, &input)
|
||||
}
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: block.Name,
|
||||
CallID: block.ID,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *codebuddyBackend) handleUser(msg codebuddySDKMessage, ch chan<- Message) {
|
||||
var content codebuddyMessageContent
|
||||
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, block := range content.Content {
|
||||
if block.Type == "tool_result" {
|
||||
resultStr := ""
|
||||
if block.Content != nil {
|
||||
resultStr = string(block.Content)
|
||||
}
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolResult,
|
||||
CallID: block.ToolUseID,
|
||||
Output: resultStr,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeCodebuddyInput(w io.Writer, prompt string) error {
|
||||
payload := map[string]any{
|
||||
"type": "user",
|
||||
"message": map[string]any{
|
||||
"role": "user",
|
||||
"content": []map[string]string{
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal codebuddy input: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Codebuddy SDK JSON types ──
|
||||
|
||||
type codebuddySDKMessage struct {
|
||||
Type string `json:"type"`
|
||||
Message json.RawMessage `json:"message,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
// result fields
|
||||
ResultText string `json:"result,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
DurationMs float64 `json:"duration_ms,omitempty"`
|
||||
NumTurns int `json:"num_turns,omitempty"`
|
||||
Usage *codebuddyUsage `json:"usage,omitempty"`
|
||||
ModelUsage map[string]codebuddyResultModelUsage `json:"modelUsage,omitempty"`
|
||||
|
||||
// log fields
|
||||
Log *codebuddyLogEntry `json:"log,omitempty"`
|
||||
}
|
||||
|
||||
type codebuddyLogEntry struct {
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type codebuddyMessageContent struct {
|
||||
Role string `json:"role"`
|
||||
Model string `json:"model"`
|
||||
Content []codebuddyContentBlock `json:"content"`
|
||||
Usage *codebuddyUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type codebuddyUsage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
||||
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
||||
}
|
||||
|
||||
type codebuddyResultModelUsage struct {
|
||||
InputTokens int64 `json:"inputTokens"`
|
||||
OutputTokens int64 `json:"outputTokens"`
|
||||
CacheReadInputTokens int64 `json:"cacheReadInputTokens"`
|
||||
CacheCreationInputTokens int64 `json:"cacheCreationInputTokens"`
|
||||
}
|
||||
|
||||
type codebuddyContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
func codebuddyResultUsage(msg codebuddySDKMessage, fallbackModel string) map[string]TokenUsage {
|
||||
if len(msg.ModelUsage) > 0 {
|
||||
usage := make(map[string]TokenUsage, len(msg.ModelUsage))
|
||||
for model, u := range msg.ModelUsage {
|
||||
if model == "" || !codebuddyUsageHasTokens(u.InputTokens, u.OutputTokens, u.CacheReadInputTokens, u.CacheCreationInputTokens) {
|
||||
continue
|
||||
}
|
||||
usage[model] = TokenUsage{
|
||||
InputTokens: u.InputTokens,
|
||||
OutputTokens: u.OutputTokens,
|
||||
CacheReadTokens: u.CacheReadInputTokens,
|
||||
CacheWriteTokens: u.CacheCreationInputTokens,
|
||||
}
|
||||
}
|
||||
if len(usage) > 0 {
|
||||
return usage
|
||||
}
|
||||
}
|
||||
|
||||
model := msg.Model
|
||||
if model == "" {
|
||||
model = fallbackModel
|
||||
}
|
||||
if msg.Usage == nil || model == "" || !codebuddyUsageHasTokens(
|
||||
msg.Usage.InputTokens,
|
||||
msg.Usage.OutputTokens,
|
||||
msg.Usage.CacheReadInputTokens,
|
||||
msg.Usage.CacheCreationInputTokens,
|
||||
) {
|
||||
return nil
|
||||
}
|
||||
return map[string]TokenUsage{
|
||||
model: {
|
||||
InputTokens: msg.Usage.InputTokens,
|
||||
OutputTokens: msg.Usage.OutputTokens,
|
||||
CacheReadTokens: msg.Usage.CacheReadInputTokens,
|
||||
CacheWriteTokens: msg.Usage.CacheCreationInputTokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func codebuddyUsageHasTokens(input, output, cacheRead, cacheWrite int64) bool {
|
||||
return input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0
|
||||
}
|
||||
474
server/pkg/agent/codebuddy_test.go
Normal file
474
server/pkg/agent/codebuddy_test.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildCodebuddyArgs_Basic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildCodebuddyArgs(ExecOptions{
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
MaxTurns: 25,
|
||||
SystemPrompt: "You are an agent.",
|
||||
}, slog.Default())
|
||||
|
||||
expected := []string{
|
||||
"-p",
|
||||
"--output-format", "stream-json",
|
||||
"--input-format", "stream-json",
|
||||
"--verbose",
|
||||
"--strict-mcp-config",
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--disallowedTools", "AskUserQuestion",
|
||||
"--model", "claude-sonnet-4-20250514",
|
||||
"--max-turns", "25",
|
||||
"--append-system-prompt", "You are an agent.",
|
||||
}
|
||||
|
||||
if len(args) != len(expected) {
|
||||
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
|
||||
}
|
||||
for i, want := range expected {
|
||||
if args[i] != want {
|
||||
t.Fatalf("args[%d] = %q, want %q\nfull args: %v", i, args[i], want, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodebuddyArgs_InjectsEffort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildCodebuddyArgs(ExecOptions{
|
||||
ThinkingLevel: "high",
|
||||
}, slog.Default())
|
||||
|
||||
found := false
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--effort" && args[i+1] == "high" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected --effort high in args: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodebuddyArgs_OmitsEffortWhenEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildCodebuddyArgs(ExecOptions{}, slog.Default())
|
||||
|
||||
for _, a := range args {
|
||||
if a == "--effort" {
|
||||
t.Fatalf("--effort should not appear when ThinkingLevel is empty: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodebuddyArgs_BlocksUserEffortOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildCodebuddyArgs(ExecOptions{
|
||||
ThinkingLevel: "medium",
|
||||
CustomArgs: []string{"--effort", "max"},
|
||||
}, slog.Default())
|
||||
|
||||
// Should have exactly one --effort (the daemon-injected one).
|
||||
count := 0
|
||||
for i, a := range args {
|
||||
if a == "--effort" {
|
||||
count++
|
||||
if i+1 < len(args) && args[i+1] != "medium" {
|
||||
t.Fatalf("expected --effort medium, got --effort %s", args[i+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected exactly 1 --effort, got %d in: %v", count, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodebuddyArgs_ExtraArgsBeforeCustomArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildCodebuddyArgs(ExecOptions{
|
||||
ExtraArgs: []string{"--output-format", "text", "--max-budget-usd", "1.00"},
|
||||
CustomArgs: []string{"--max-budget-usd", "2.00", "--permission-mode", "plan"},
|
||||
}, slog.Default())
|
||||
|
||||
joined := strings.Join(args, " ")
|
||||
// Blocked flags should be filtered from both layers.
|
||||
if strings.Contains(joined, "--output-format text") || strings.Contains(joined, "--permission-mode plan") {
|
||||
t.Fatalf("blocked args should be filtered from both layers: %v", args)
|
||||
}
|
||||
|
||||
extraIdx, customIdx := -1, -1
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--max-budget-usd" && args[i+1] == "1.00" {
|
||||
extraIdx = i
|
||||
}
|
||||
if args[i] == "--max-budget-usd" && args[i+1] == "2.00" {
|
||||
customIdx = i
|
||||
}
|
||||
}
|
||||
if extraIdx == -1 || customIdx == -1 || extraIdx > customIdx {
|
||||
t.Fatalf("expected extra args before custom args, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodebuddyArgs_Resume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildCodebuddyArgs(ExecOptions{
|
||||
ResumeSessionID: "sess-abc123",
|
||||
}, slog.Default())
|
||||
|
||||
found := false
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--resume" && args[i+1] == "sess-abc123" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected --resume sess-abc123 in args: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyExecute_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script fixture is POSIX-only")
|
||||
}
|
||||
|
||||
fakePath := filepath.Join(t.TempDir(), "codebuddy")
|
||||
script := "#!/bin/sh\n" +
|
||||
"cat >/dev/null\n" +
|
||||
`printf '%s\n' '{"type":"system","session_id":"sess-cb-001"}'` + "\n" +
|
||||
`printf '%s\n' '{"type":"assistant","message":{"role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Hello from codebuddy"}]}}'` + "\n" +
|
||||
`printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-cb-001","result":"Hello from codebuddy","modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":100,"outputTokens":50,"cacheReadInputTokens":10,"cacheCreationInputTokens":5}}}'` + "\n"
|
||||
writeTestExecutable(t, fakePath, []byte(script))
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{ExecutablePath: fakePath, Logger: slog.Default()}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := b.Execute(ctx, "say hello", ExecOptions{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
// Drain messages.
|
||||
var gotText bool
|
||||
for msg := range session.Messages {
|
||||
if msg.Type == MessageText && msg.Content == "Hello from codebuddy" {
|
||||
gotText = true
|
||||
}
|
||||
}
|
||||
if !gotText {
|
||||
t.Fatal("expected text message 'Hello from codebuddy'")
|
||||
}
|
||||
|
||||
select {
|
||||
case result, ok := <-session.Result:
|
||||
if !ok {
|
||||
t.Fatal("result channel closed without a value")
|
||||
}
|
||||
if result.Status != "completed" {
|
||||
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
||||
}
|
||||
if result.Output != "Hello from codebuddy" {
|
||||
t.Fatalf("expected output 'Hello from codebuddy', got %q", result.Output)
|
||||
}
|
||||
if result.SessionID != "sess-cb-001" {
|
||||
t.Fatalf("expected session_id=sess-cb-001, got %q", result.SessionID)
|
||||
}
|
||||
usage, ok := result.Usage["claude-sonnet-4-20250514"]
|
||||
if !ok {
|
||||
t.Fatalf("expected usage for claude-sonnet-4-20250514, got %#v", result.Usage)
|
||||
}
|
||||
if usage.InputTokens != 100 || usage.OutputTokens != 50 || usage.CacheReadTokens != 10 || usage.CacheWriteTokens != 5 {
|
||||
t.Fatalf("unexpected usage: %+v", usage)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("timeout waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyExecute_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{ExecutablePath: "/nonexistent/path/codebuddy", Logger: slog.Default()}}
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := b.Execute(ctx, "prompt", ExecOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing executable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "codebuddy executable not found") {
|
||||
t.Fatalf("expected 'codebuddy executable not found' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyExecuteSurfacesStderr(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script fixture is POSIX-only")
|
||||
}
|
||||
|
||||
fakePath := filepath.Join(t.TempDir(), "codebuddy")
|
||||
script := "#!/bin/sh\n" +
|
||||
"cat >/dev/null\n" +
|
||||
"echo \"FATAL ERROR: segfault in codebuddy runtime\" >&2\n" +
|
||||
"exit 1\n"
|
||||
writeTestExecutable(t, fakePath, []byte(script))
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{ExecutablePath: fakePath, Logger: slog.Default()}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := b.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
// Drain messages.
|
||||
go func() {
|
||||
for range session.Messages {
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case result, ok := <-session.Result:
|
||||
if !ok {
|
||||
t.Fatal("result channel closed without a value")
|
||||
}
|
||||
if result.Status != "failed" {
|
||||
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Error, "codebuddy exited with error") {
|
||||
t.Fatalf("expected error to mention exit, got %q", result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Error, "segfault in codebuddy runtime") {
|
||||
t.Fatalf("expected error to include stderr content, got %q", result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Error, "codebuddy stderr:") {
|
||||
t.Fatalf("expected stderr label in error, got %q", result.Error)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("timeout waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCodebuddyInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf strings.Builder
|
||||
err := writeCodebuddyInput(&buf, "hello world")
|
||||
if err != nil {
|
||||
t.Fatalf("writeCodebuddyInput: %v", err)
|
||||
}
|
||||
|
||||
data := buf.String()
|
||||
if len(data) == 0 || data[len(data)-1] != '\n' {
|
||||
t.Fatalf("expected newline-terminated payload, got %q", data)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(data)), &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if payload["type"] != "user" {
|
||||
t.Fatalf("expected type user, got %v", payload["type"])
|
||||
}
|
||||
|
||||
message, ok := payload["message"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected message object, got %T", payload["message"])
|
||||
}
|
||||
if message["role"] != "user" {
|
||||
t.Fatalf("expected role user, got %v", message["role"])
|
||||
}
|
||||
|
||||
content, ok := message["content"].([]any)
|
||||
if !ok || len(content) != 1 {
|
||||
t.Fatalf("expected one content block, got %v", message["content"])
|
||||
}
|
||||
block, ok := content[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected content block object, got %T", content[0])
|
||||
}
|
||||
if block["type"] != "text" || block["text"] != "hello world" {
|
||||
t.Fatalf("unexpected content block: %v", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyHandleAssistantText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
msg := codebuddySDKMessage{
|
||||
Type: "assistant",
|
||||
Message: mustMarshal(t, codebuddyMessageContent{
|
||||
Role: "assistant",
|
||||
Content: []codebuddyContentBlock{
|
||||
{Type: "text", Text: "codebuddy says hi"},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
b.handleAssistant(msg, ch, &output, make(map[string]TokenUsage))
|
||||
|
||||
if output.String() != "codebuddy says hi" {
|
||||
t.Fatalf("expected output 'codebuddy says hi', got %q", output.String())
|
||||
}
|
||||
select {
|
||||
case m := <-ch:
|
||||
if m.Type != MessageText || m.Content != "codebuddy says hi" {
|
||||
t.Fatalf("unexpected message: %+v", m)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected message on channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodebuddyModels_FullHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
helpOutput := `Usage: codebuddy [options] [command] [prompt]
|
||||
|
||||
Options:
|
||||
--model <model> Model for the current session. Please provide the model ID. Currently supported: (claude-sonnet-4.6, claude-opus-4.7, gemini-3.1-pro, gpt-5.5, glm-5.1-ioa, minimax-m2.7-ioa, kimi-k2.6-ioa, hy3-preview-ioa, deepseek-v3-2-volc-ioa)
|
||||
--effort <level> Reasoning effort level (low, medium, high, xhigh)
|
||||
`
|
||||
models := parseCodebuddyModels(helpOutput)
|
||||
if len(models) != 9 {
|
||||
t.Fatalf("expected 9 models, got %d: %+v", len(models), models)
|
||||
}
|
||||
if !models[0].Default {
|
||||
t.Error("first model should be marked as default")
|
||||
}
|
||||
if models[0].ID != "claude-sonnet-4.6" {
|
||||
t.Errorf("first model ID = %q, want claude-sonnet-4.6", models[0].ID)
|
||||
}
|
||||
if models[0].Provider != "anthropic" {
|
||||
t.Errorf("claude model provider = %q, want anthropic", models[0].Provider)
|
||||
}
|
||||
// Spot check providers
|
||||
providers := map[string]string{}
|
||||
for _, m := range models {
|
||||
providers[m.ID] = m.Provider
|
||||
}
|
||||
checks := map[string]string{
|
||||
"gpt-5.5": "openai",
|
||||
"gemini-3.1-pro": "google",
|
||||
"glm-5.1-ioa": "zhipu",
|
||||
"minimax-m2.7-ioa": "minimax",
|
||||
"kimi-k2.6-ioa": "kimi",
|
||||
"hy3-preview-ioa": "hunyuan",
|
||||
"deepseek-v3-2-volc-ioa": "deepseek",
|
||||
}
|
||||
for id, want := range checks {
|
||||
if got := providers[id]; got != want {
|
||||
t.Errorf("provider(%q) = %q, want %q", id, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodebuddyModels_Malformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
models := parseCodebuddyModels("totally unrelated output\nno model line here")
|
||||
if len(models) != 0 {
|
||||
t.Fatalf("expected 0 models from malformed output, got %d", len(models))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodebuddyEffortHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
helpOutput := ` --effort <level> Reasoning effort level (low, medium, high, xhigh)`
|
||||
levels := parseCodebuddyEffortHelp(helpOutput)
|
||||
expected := []string{"low", "medium", "high", "xhigh"}
|
||||
if len(levels) != len(expected) {
|
||||
t.Fatalf("expected %d levels, got %d: %v", len(expected), len(levels), levels)
|
||||
}
|
||||
for i, l := range levels {
|
||||
if l != expected[i] {
|
||||
t.Errorf("level[%d]: expected %q, got %q", i, expected[i], l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodebuddyEffortHelp_Missing(t *testing.T) {
|
||||
t.Parallel()
|
||||
levels := parseCodebuddyEffortHelp("no effort line here")
|
||||
if len(levels) != 0 {
|
||||
t.Fatalf("expected nil for missing effort line, got %v", levels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnownThinkingValue_Codebuddy(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{"", true},
|
||||
{"low", true},
|
||||
{"medium", true},
|
||||
{"high", true},
|
||||
{"xhigh", true},
|
||||
{"max", false},
|
||||
{"none", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsKnownThinkingValue("codebuddy", tc.value)
|
||||
if got != tc.want {
|
||||
t.Errorf("IsKnownThinkingValue(codebuddy, %q) = %v, want %v", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyHandleUserToolResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
msg := codebuddySDKMessage{
|
||||
Type: "user",
|
||||
Message: mustMarshal(t, codebuddyMessageContent{
|
||||
Role: "user",
|
||||
Content: []codebuddyContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: "call-cb-1",
|
||||
Content: mustMarshal(t, "tool output here"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
b.handleUser(msg, ch)
|
||||
|
||||
select {
|
||||
case m := <-ch:
|
||||
if m.Type != MessageToolResult || m.CallID != "call-cb-1" {
|
||||
t.Fatalf("unexpected message: %+v", m)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected message on channel")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -141,6 +142,15 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod
|
||||
return cachedDiscovery(providerType, func() ([]Model, error) {
|
||||
return discoverOpenclawAgents(ctx, executablePath)
|
||||
})
|
||||
case "codebuddy":
|
||||
return cachedDiscovery(providerType, func() ([]Model, error) {
|
||||
models, err := discoverCodebuddyModels(ctx, executablePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
annotateCodebuddyThinking(ctx, models, executablePath)
|
||||
return models, nil
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown agent type: %q", providerType)
|
||||
}
|
||||
@@ -1038,3 +1048,119 @@ func isOpenclawIdentifier(s string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── CodeBuddy model discovery ──
|
||||
|
||||
// codebuddyModelRe matches the `--model <model> ... Currently supported: (m1, m2, ...)`
|
||||
// line in `codebuddy --help` output.
|
||||
var codebuddyModelRe = regexp.MustCompile(`--model\s*<[^>]+>\s*.*?Currently supported:\s*\(([^)]+)\)`)
|
||||
|
||||
// discoverCodebuddyModels runs `codebuddy --help` and extracts the
|
||||
// supported model list from its output. Falls back to a static list
|
||||
// when the binary is missing or the output cannot be parsed.
|
||||
func discoverCodebuddyModels(ctx context.Context, executablePath string) ([]Model, error) {
|
||||
if executablePath == "" {
|
||||
executablePath = "codebuddy"
|
||||
}
|
||||
if _, err := exec.LookPath(executablePath); err != nil {
|
||||
return codebuddyStaticModels(), nil
|
||||
}
|
||||
// CodeBuddy's --help is slow (~30s) because it fetches remote model
|
||||
// metadata on startup. 35s accommodates that while still bounding the
|
||||
// wait. The result is cached (modelCacheTTL) so subsequent calls are
|
||||
// instant until the cache expires.
|
||||
runCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(runCtx, executablePath, "--help")
|
||||
hideAgentWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return codebuddyStaticModels(), nil
|
||||
}
|
||||
models := parseCodebuddyModels(string(out))
|
||||
if len(models) == 0 {
|
||||
return codebuddyStaticModels(), nil
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// parseCodebuddyModels extracts model IDs from codebuddy --help output.
|
||||
// The help text contains a line like:
|
||||
//
|
||||
// --model <model> ... Currently supported: (model1, model2, ...)
|
||||
//
|
||||
// The first model in the list is marked as default.
|
||||
func parseCodebuddyModels(helpOutput string) []Model {
|
||||
match := codebuddyModelRe.FindStringSubmatch(helpOutput)
|
||||
if len(match) < 2 {
|
||||
return nil
|
||||
}
|
||||
raw := strings.Split(match[1], ",")
|
||||
var models []Model
|
||||
for _, s := range raw {
|
||||
id := strings.TrimSpace(s)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
models = append(models, Model{
|
||||
ID: id,
|
||||
Label: codebuddyModelLabel(id),
|
||||
Provider: codebuddyModelProvider(id),
|
||||
Default: len(models) == 0,
|
||||
})
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
// codebuddyModelProvider infers a provider name from a model ID prefix.
|
||||
func codebuddyModelProvider(id string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "claude-"):
|
||||
return "anthropic"
|
||||
case strings.HasPrefix(id, "gemini-"):
|
||||
return "google"
|
||||
case strings.HasPrefix(id, "gpt-"):
|
||||
return "openai"
|
||||
case strings.HasPrefix(id, "glm-"):
|
||||
return "zhipu"
|
||||
case strings.HasPrefix(id, "minimax-"):
|
||||
return "minimax"
|
||||
case strings.HasPrefix(id, "kimi-"):
|
||||
return "kimi"
|
||||
case len(id) >= 3 && id[0] == 'h' && id[1] == 'y' && id[2] >= '0' && id[2] <= '9':
|
||||
return "hunyuan"
|
||||
case strings.HasPrefix(id, "deepseek-"):
|
||||
return "deepseek"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// codebuddyModelLabel generates a human-readable label from a model ID.
|
||||
// Capitalizes each dash-separated part; special-cases GPT/GLM to uppercase
|
||||
// and rewrites the "-ioa" suffix as "IOA".
|
||||
func codebuddyModelLabel(id string) string {
|
||||
parts := strings.Split(id, "-")
|
||||
for i, p := range parts {
|
||||
if strings.EqualFold(p, "gpt") || strings.EqualFold(p, "glm") {
|
||||
parts[i] = strings.ToUpper(p)
|
||||
} else if strings.EqualFold(p, "ioa") {
|
||||
parts[i] = "IOA"
|
||||
} else if len(p) > 0 {
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// codebuddyStaticModels is the fallback catalog when dynamic discovery
|
||||
// fails (binary missing, parse error, timeout).
|
||||
func codebuddyStaticModels() []Model {
|
||||
return []Model{
|
||||
{ID: "claude-sonnet-4.6", Label: "Claude Sonnet 4.6", Provider: "anthropic", Default: true},
|
||||
{ID: "claude-opus-4.7", Label: "Claude Opus 4.7", Provider: "anthropic"},
|
||||
{ID: "gemini-3.1-pro", Label: "Gemini 3.1 Pro", Provider: "google"},
|
||||
{ID: "gpt-5.5", Label: "GPT 5.5", Provider: "openai"},
|
||||
{ID: "deepseek-v3-2-volc-ioa", Label: "Deepseek V3 2 Volc IOA", Provider: "deepseek"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +356,101 @@ func parseCodexDebugModels(raw []byte) map[string]*ModelThinking {
|
||||
return out
|
||||
}
|
||||
|
||||
// ── CodeBuddy ────────────────────────────────────────────────────────
|
||||
//
|
||||
// CodeBuddy uses the same `--effort <level>` flag as Claude but with a
|
||||
// different level set (no `max`). Discovery parses `--help` identically
|
||||
// to the claude approach. All models get the same effort levels since
|
||||
// CodeBuddy doesn't document per-model restrictions.
|
||||
|
||||
var codebuddyEffortRe = regexp.MustCompile(`--effort\s*(?:<[^>]+>)?\s*[^(]*\(([^)]+)\)`)
|
||||
|
||||
var codebuddyEffortLabel = map[string]string{
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"xhigh": "Extra high",
|
||||
}
|
||||
|
||||
var codebuddyStaticEffortFallback = []string{"low", "medium", "high", "xhigh"}
|
||||
|
||||
func annotateCodebuddyThinking(ctx context.Context, models []Model, executablePath string) {
|
||||
if executablePath == "" {
|
||||
executablePath = "codebuddy"
|
||||
}
|
||||
version, _ := DetectVersion(ctx, executablePath)
|
||||
key := thinkingCacheKey{provider: "codebuddy", executablePath: executablePath, cliVersion: version}
|
||||
if cached, ok := thinkingCacheGet(key); ok {
|
||||
for i := range models {
|
||||
if t, ok := cached[models[i].ID]; ok && t != nil {
|
||||
models[i].Thinking = t
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
levels := codebuddyEffortSuperset(ctx, executablePath)
|
||||
thinkingLevels := make([]ThinkingLevel, 0, len(levels))
|
||||
for _, value := range levels {
|
||||
label, ok := codebuddyEffortLabel[value]
|
||||
if !ok {
|
||||
label = strings.Title(value) //nolint:staticcheck
|
||||
}
|
||||
thinkingLevels = append(thinkingLevels, ThinkingLevel{Value: value, Label: label})
|
||||
}
|
||||
|
||||
result := map[string]*ModelThinking{}
|
||||
if len(thinkingLevels) > 0 {
|
||||
thinking := &ModelThinking{
|
||||
SupportedLevels: thinkingLevels,
|
||||
DefaultLevel: "medium",
|
||||
}
|
||||
for _, m := range models {
|
||||
result[m.ID] = thinking
|
||||
}
|
||||
}
|
||||
thinkingCachePut(key, result)
|
||||
|
||||
for i := range models {
|
||||
if t, ok := result[models[i].ID]; ok && t != nil {
|
||||
models[i].Thinking = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func codebuddyEffortSuperset(ctx context.Context, executablePath string) []string {
|
||||
// CodeBuddy's --help is slow (~30s); use a generous timeout.
|
||||
runCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(runCtx, executablePath, "--help")
|
||||
hideAgentWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return append([]string(nil), codebuddyStaticEffortFallback...)
|
||||
}
|
||||
parsed := parseCodebuddyEffortHelp(string(out))
|
||||
if len(parsed) == 0 {
|
||||
return append([]string(nil), codebuddyStaticEffortFallback...)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseCodebuddyEffortHelp(helpText string) []string {
|
||||
match := codebuddyEffortRe.FindStringSubmatch(helpText)
|
||||
if len(match) < 2 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, raw := range strings.Split(match[1], ",") {
|
||||
token := strings.TrimSpace(raw)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, token)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Shared validation ────────────────────────────────────────────────
|
||||
|
||||
// ValidateThinkingLevel reports whether `value` is in the supported
|
||||
@@ -446,6 +541,12 @@ var providerThinkingEnums = map[string]map[string]bool{
|
||||
"high": true,
|
||||
"xhigh": true,
|
||||
},
|
||||
"codebuddy": {
|
||||
"low": true,
|
||||
"medium": true,
|
||||
"high": true,
|
||||
"xhigh": true,
|
||||
},
|
||||
}
|
||||
|
||||
// IsKnownThinkingValue reports whether `value` is a recognised effort
|
||||
|
||||
Reference in New Issue
Block a user