From c0b4e7e8b872af41bee781960bb322c9dbc69e6f Mon Sep 17 00:00:00 2001 From: devv-eve Date: Thu, 16 Apr 2026 00:54:21 -0700 Subject: [PATCH] feat(agent): add Cursor Agent CLI runtime support (#1057) * feat(agent): add Cursor Agent CLI runtime support Add cursor-agent as a new agent backend, following the same pattern as existing providers. The implementation spawns cursor-agent CLI with stream-json output, parses JSONL events into the unified Message type, and supports session resume, usage tracking, and auto-approval (--yolo). Changes: - server/pkg/agent/cursor.go: cursorBackend implementation - server/pkg/agent/cursor_test.go: unit tests for args, parsing, errors - server/pkg/agent/agent.go: register "cursor" in New() factory - server/internal/daemon/config.go: probe cursor-agent in PATH - server/internal/daemon/execenv/context.go: cursor skill discovery path - server/internal/daemon/execenv/runtime_config.go: AGENTS.md injection - packages/views/.../provider-logo.tsx: cursor logo in UI Co-Authored-By: Claude Opus 4.6 (1M context) * fix(agent): address PR review for cursor backend 1. Fix token usage double-counting: usage is now taken exclusively from "result" events (session totals). Per-message usage in "assistant" events is intentionally ignored. "step_finish" usage is only used as fallback when no "result" usage is available. 2. Remove dead code: isCursorUnknownSessionError() and its regex were defined but never called. Removed along with corresponding test. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(agent): add missing CustomArgs, SystemPrompt, MaxTurns, and debug logging to cursor backend - Add cursorBlockedArgs and filterCustomArgs support for safe custom arg passthrough - Add --system-prompt and --max-turns flag support to buildCursorArgs - Add debug logging of command args before execution (consistent with all other backends) - Move stdout-close goroutine inside main goroutine (consistent with claude.go pattern) - Add tests for SystemPrompt/MaxTurns and CustomArgs filtering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: make daemon uses local profile & update Cursor logo to official brand - Makefile: make daemon now runs 'daemon start --profile local' for local dev - Replace Cursor runtime logo with official brand SVG (removed background rect) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(agent): remove unsupported --system-prompt and --max-turns from cursor-agent cursor-agent CLI does not support these flags. Instructions are already injected via AGENTS.md and .cursor/skills/ files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(agent): prevent step_finish + result usage double-counting in cursor Split usage accumulation into separate stepUsage and resultUsage maps. After stream ends, use resultUsage if available (session totals from result event), otherwise fall back to stepUsage (sum of step_finish). This prevents 2x counting when result.usage already includes totals. Added table-driven test covering: result-only, step_finish-only, step_finish+result (no double count), and multi-model scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(agent): fix misleading comment on cursor -p flag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Devv Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: yushen Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Makefile | 2 +- .../runtimes/components/provider-logo.tsx | 16 + server/internal/daemon/config.go | 9 +- server/internal/daemon/execenv/context.go | 4 + .../internal/daemon/execenv/runtime_config.go | 7 +- server/pkg/agent/agent.go | 6 +- server/pkg/agent/cursor.go | 419 +++++++++++++++++ server/pkg/agent/cursor_test.go | 435 ++++++++++++++++++ 8 files changed, 891 insertions(+), 7 deletions(-) create mode 100644 server/pkg/agent/cursor.go create mode 100644 server/pkg/agent/cursor_test.go diff --git a/Makefile b/Makefile index 7ff4e34b4..3cad49be5 100644 --- a/Makefile +++ b/Makefile @@ -182,7 +182,7 @@ server: cd server && go run ./cmd/server daemon: - @$(MAKE) multica MULTICA_ARGS="daemon" + @$(MAKE) multica MULTICA_ARGS="daemon start --profile local" cli: @$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)" diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx index 809b67c32..ea8d5f519 100644 --- a/packages/views/runtimes/components/provider-logo.tsx +++ b/packages/views/runtimes/components/provider-logo.tsx @@ -88,6 +88,20 @@ function PiLogo({ className }: { className: string }) { ); } +// Cursor — official brand logo from Cursor brand assets +function CursorLogo({ className }: { className: string }) { + return ( + + + + + + + + + ); +} + export function ProviderLogo({ provider, className = "h-4 w-4", @@ -108,6 +122,8 @@ export function ProviderLogo({ return ; case "pi": return ; + case "cursor": + return ; default: return ; } diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index cd1e5e948..034ab570f 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -127,8 +127,15 @@ func LoadConfig(overrides Overrides) (Config, error) { Model: strings.TrimSpace(os.Getenv("MULTICA_PI_MODEL")), } } + cursorPath := envOrDefault("MULTICA_CURSOR_PATH", "cursor-agent") + if _, err := exec.LookPath(cursorPath); err == nil { + agents["cursor"] = AgentEntry{ + Path: cursorPath, + Model: strings.TrimSpace(os.Getenv("MULTICA_CURSOR_MODEL")), + } + } if len(agents) == 0 { - return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, hermes, gemini, or pi and ensure it is on PATH") + return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, hermes, gemini, pi, or cursor-agent and ensure it is on PATH") } // Host info diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index 5a9cf91c7..071707a46 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -15,6 +15,7 @@ import ( // Codex: skills → handled separately in Prepare via codex-home // OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery) // Pi: skills → {workDir}/.pi/agent/skills/{name}/SKILL.md (native discovery) +// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery) // Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error { contextDir := filepath.Join(workDir, ".agent_context") @@ -58,6 +59,9 @@ func resolveSkillsDir(workDir, provider string) (string, error) { case "pi": // Pi natively discovers skills from .pi/agent/skills/ in the workdir. skillsDir = filepath.Join(workDir, ".pi", "agent", "skills") + case "cursor": + // Cursor natively discovers skills from .cursor/skills/ in the workdir. + skillsDir = filepath.Join(workDir, ".cursor", "skills") default: // Fallback: write to .agent_context/skills/ (referenced by meta config). skillsDir = 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 c8a5542ff..fa79e75c4 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -16,13 +16,14 @@ import ( // For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/) // For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI) // For Pi: writes {workDir}/AGENTS.md (skills discovered natively from ~/.pi/agent/skills/) +// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/) func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error { content := buildMetaSkillContent(provider, ctx) switch provider { case "claude": return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644) - case "codex", "opencode", "openclaw", "pi": + case "codex", "opencode", "openclaw", "pi", "cursor": return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644) case "gemini": return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644) @@ -141,8 +142,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { case "claude": // Claude discovers skills natively from .claude/skills/ — just list names. b.WriteString("You have the following skills installed (discovered automatically):\n\n") - case "codex", "opencode", "openclaw", "pi": - // Codex, OpenCode, OpenClaw, and Pi discover skills natively from their respective paths — just list names. + case "codex", "opencode", "openclaw", "pi", "cursor": + // Codex, OpenCode, OpenClaw, Pi, and Cursor discover skills natively from their respective paths — just list names. b.WriteString("You have the following skills installed (discovered automatically):\n\n") case "gemini": // Gemini reads GEMINI.md directly; point it at the fallback skills dir. diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index 99a9d1f7e..6520afd56 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -89,7 +89,7 @@ type Config struct { } // New creates a Backend for the given agent type. -// Supported types: "claude", "codex", "opencode", "openclaw", "hermes", "gemini", "pi". +// Supported types: "claude", "codex", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor". func New(agentType string, cfg Config) (Backend, error) { if cfg.Logger == nil { cfg.Logger = slog.Default() @@ -110,8 +110,10 @@ func New(agentType string, cfg Config) (Backend, error) { return &geminiBackend{cfg: cfg}, nil case "pi": return &piBackend{cfg: cfg}, nil + case "cursor": + return &cursorBackend{cfg: cfg}, nil default: - return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes, gemini, pi)", agentType) + return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes, gemini, pi, cursor)", agentType) } } diff --git a/server/pkg/agent/cursor.go b/server/pkg/agent/cursor.go new file mode 100644 index 000000000..3df917bdc --- /dev/null +++ b/server/pkg/agent/cursor.go @@ -0,0 +1,419 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "os/exec" + "regexp" + "strings" + "time" +) + +// cursorBackend implements Backend by spawning the Cursor Agent CLI +// (cursor-agent) with --output-format stream-json and parsing the JSONL +// event stream. The protocol is similar to Claude Code's stream-json +// format: events are newline-delimited JSON objects with a "type" field. +type cursorBackend struct { + cfg Config +} + +func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { + execPath := b.cfg.ExecutablePath + if execPath == "" { + execPath = "cursor-agent" + } + if _, err := exec.LookPath(execPath); err != nil { + return nil, fmt.Errorf("cursor-agent 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 := buildCursorArgs(prompt, opts, b.cfg.Logger) + + cmd := exec.CommandContext(runCtx, execPath, args...) + b.cfg.Logger.Debug("agent command", "exec", execPath, "args", args) + cmd.WaitDelay = 20 * 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("cursor stdout pipe: %w", err) + } + cmd.Stderr = newLogWriter(b.cfg.Logger, "[cursor:stderr] ") + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start cursor-agent: %w", err) + } + + b.cfg.Logger.Info("cursor-agent started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + + msgCh := make(chan Message, 256) + resCh := make(chan Result, 1) + + go func() { + defer cancel() + defer close(msgCh) + defer close(resCh) + + // Close stdout when the context is cancelled so scanner.Scan() unblocks. + go func() { + <-runCtx.Done() + _ = stdout.Close() + }() + + startTime := time.Now() + var output strings.Builder + var sessionID string + finalStatus := "completed" + var finalError string + // stepUsage accumulates per-step token counts from "step_finish" events. + // resultUsage holds authoritative session totals from "result" events. + // If the result event includes usage, we use resultUsage exclusively; + // otherwise we fall back to stepUsage. + stepUsage := make(map[string]TokenUsage) + resultUsage := make(map[string]TokenUsage) + hasResultUsage := false + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + + for scanner.Scan() { + raw := scanner.Text() + line := normalizeCursorStreamLine(raw) + if line == "" { + continue + } + + var evt cursorStreamEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + + if sid := evt.readSessionID(); sid != "" { + sessionID = sid + } + + switch evt.Type { + case "system": + if evt.Subtype == "init" { + trySend(msgCh, Message{Type: MessageStatus, Status: "running"}) + } + if evt.Subtype == "error" { + errMsg := cursorErrorText(&evt) + if errMsg != "" { + trySend(msgCh, Message{Type: MessageError, Content: errMsg}) + } + } + + case "assistant": + b.handleCursorAssistant(&evt, msgCh, &output) + + case "tool_use": + var params map[string]any + if evt.Parameters != nil { + _ = json.Unmarshal(evt.Parameters, ¶ms) + } + trySend(msgCh, Message{ + Type: MessageToolUse, + Tool: evt.ToolName, + CallID: evt.ToolID, + Input: params, + }) + + case "tool_result": + trySend(msgCh, Message{ + Type: MessageToolResult, + CallID: evt.ToolID, + Output: evt.Output, + }) + + case "result": + if evt.IsError || evt.Subtype == "error" { + finalStatus = "failed" + finalError = cursorErrorText(&evt) + } + if evt.ResultText != "" && output.Len() == 0 { + output.WriteString(evt.ResultText) + } + b.accumulateResultUsage(resultUsage, &evt) + if evt.Usage != nil { + hasResultUsage = true + } + + case "error": + errMsg := cursorErrorText(&evt) + if errMsg != "" { + finalError = errMsg + } + trySend(msgCh, Message{Type: MessageError, Content: errMsg}) + + case "text": + if evt.Part != nil { + var part cursorTextPart + _ = json.Unmarshal(evt.Part, &part) + if part.Text != "" { + output.WriteString(part.Text) + trySend(msgCh, Message{Type: MessageText, Content: part.Text}) + } + } + + case "step_finish": + if evt.Part != nil { + var part cursorStepFinishPart + _ = json.Unmarshal(evt.Part, &part) + model := evt.Model + if model == "" { + model = "cursor" + } + u := stepUsage[model] + u.InputTokens += int64(part.Tokens.Input) + u.OutputTokens += int64(part.Tokens.Output) + u.CacheReadTokens += int64(part.Tokens.Cache.Read) + stepUsage[model] = u + } + } + } + + // Use result usage if available (session totals); otherwise fall back + // to accumulated step_finish usage. + if !hasResultUsage { + resultUsage = stepUsage + } + + exitErr := cmd.Wait() + duration := time.Since(startTime) + + if runCtx.Err() == context.DeadlineExceeded { + finalStatus = "timeout" + finalError = fmt.Sprintf("cursor-agent 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("cursor-agent exited with error: %v", exitErr) + } + + b.cfg.Logger.Info("cursor-agent finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + + resCh <- Result{ + Status: finalStatus, + Output: output.String(), + Error: finalError, + DurationMs: duration.Milliseconds(), + SessionID: sessionID, + Usage: resultUsage, + } + }() + + return &Session{Messages: msgCh, Result: resCh}, nil +} + +func (b *cursorBackend) handleCursorAssistant(evt *cursorStreamEvent, ch chan<- Message, output *strings.Builder) { + if evt.Message == nil { + return + } + + var content cursorAssistantMessage + if err := json.Unmarshal(evt.Message, &content); err != nil { + return + } + + // Note: per-message usage in assistant events is intentionally ignored. + // Token usage is taken exclusively from "result" events (session totals) + // to avoid double-counting. + + for _, block := range content.Content { + switch block.Type { + case "output_text", "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 *cursorBackend) accumulateResultUsage(usage map[string]TokenUsage, evt *cursorStreamEvent) { + if evt.Usage == nil { + return + } + model := evt.Model + if model == "" { + model = "cursor" + } + u := usage[model] + u.InputTokens += evt.Usage.InputTokens + u.OutputTokens += evt.Usage.OutputTokens + u.CacheReadTokens += evt.Usage.CacheReadInputTokens + usage[model] = u +} + +// ── Cursor stream-json types ── + +type cursorStreamEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + SessionID string `json:"session_id,omitempty"` + Model string `json:"model,omitempty"` + + // assistant fields + Message json.RawMessage `json:"message,omitempty"` + + // tool_use fields + ToolName string `json:"tool_name,omitempty"` + ToolID string `json:"tool_id,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` + + // tool_result fields + Output string `json:"output,omitempty"` + + // result fields + ResultText string `json:"result,omitempty"` + IsError bool `json:"is_error,omitempty"` + Usage *cursorUsage `json:"usage,omitempty"` + TotalCost float64 `json:"total_cost_usd,omitempty"` + + // error fields + ErrorMsg string `json:"error,omitempty"` + Detail string `json:"detail,omitempty"` + + // legacy compat + Part json.RawMessage `json:"part,omitempty"` +} + +func (evt *cursorStreamEvent) readSessionID() string { + if s := strings.TrimSpace(evt.SessionID); s != "" { + return s + } + return "" +} + +type cursorUsage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadInputTokens int64 `json:"cached_input_tokens"` +} + +type cursorAssistantMessage struct { + Model string `json:"model"` + Content []cursorContentBlock `json:"content"` + Usage *cursorUsage `json:"usage,omitempty"` +} + +type cursorContentBlock 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"` +} + +type cursorTextPart struct { + Text string `json:"text"` +} + +type cursorStepFinishPart struct { + Tokens struct { + Input int `json:"input"` + Output int `json:"output"` + Cache struct { + Read int `json:"read"` + } `json:"cache"` + } `json:"tokens"` + Cost float64 `json:"cost"` +} + +// ── Helpers ── + +// normalizeCursorStreamLine handles the stdout:/stderr: prefix that Cursor +// CLI may emit in stream-json mode. Returns the trimmed JSON line. +func normalizeCursorStreamLine(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + // Cursor CLI may prefix lines with "stdout:" or "stderr:" — strip it. + if idx := cursorStreamPrefixRe.FindStringIndex(trimmed); idx != nil { + return strings.TrimSpace(trimmed[idx[1]:]) + } + return trimmed +} + +var cursorStreamPrefixRe = regexp.MustCompile(`^(?i)(stdout|stderr)\s*[:=]?\s*`) + +func cursorErrorText(evt *cursorStreamEvent) string { + if evt.ErrorMsg != "" { + return evt.ErrorMsg + } + if evt.Detail != "" { + return evt.Detail + } + if evt.ResultText != "" { + return evt.ResultText + } + return "" +} + +// cursorBlockedArgs are flags hardcoded by the daemon that must not be +// overridden by user-configured custom_args. Overriding these would break +// the daemon↔cursor-agent communication protocol. +var cursorBlockedArgs = map[string]blockedArgMode{ + "-p": blockedStandalone, // non-interactive print mode + "--output-format": blockedWithValue, // stream-json protocol + "--yolo": blockedStandalone, // auto-approval for autonomous operation +} + +// buildCursorArgs assembles the argv for a one-shot cursor-agent invocation. +// +// Usage: cursor-agent chat -p --output-format stream-json +// +// --workspace --yolo [--model ] [--resume ] +func buildCursorArgs(prompt string, opts ExecOptions, logger *slog.Logger) []string { + args := []string{ + "chat", + "-p", prompt, + "--output-format", "stream-json", + "--yolo", + } + if opts.Cwd != "" { + args = append(args, "--workspace", opts.Cwd) + } + if opts.Model != "" { + args = append(args, "--model", opts.Model) + } + // NOTE: cursor-agent CLI does not support --system-prompt or --max-turns. + // Instructions are injected via AGENTS.md and .cursor/skills/ files instead. + if opts.ResumeSessionID != "" { + args = append(args, "--resume", opts.ResumeSessionID) + } + args = append(args, filterCustomArgs(opts.CustomArgs, cursorBlockedArgs, logger)...) + return args +} diff --git a/server/pkg/agent/cursor_test.go b/server/pkg/agent/cursor_test.go new file mode 100644 index 000000000..9390885c4 --- /dev/null +++ b/server/pkg/agent/cursor_test.go @@ -0,0 +1,435 @@ +package agent + +import ( + "encoding/json" + "log/slog" + "strings" + "testing" +) + +func TestNewReturnsCursorBackend(t *testing.T) { + t.Parallel() + b, err := New("cursor", Config{ExecutablePath: "/nonexistent/cursor-agent"}) + if err != nil { + t.Fatalf("New(cursor) error: %v", err) + } + if _, ok := b.(*cursorBackend); !ok { + t.Fatalf("expected *cursorBackend, got %T", b) + } +} + +func TestBuildCursorArgs(t *testing.T) { + t.Parallel() + + args := buildCursorArgs("do something", ExecOptions{ + Cwd: "/tmp/work", + Model: "composer-1.5", + }, slog.Default()) + + expected := []string{ + "chat", + "-p", "do something", + "--output-format", "stream-json", + "--yolo", + "--workspace", "/tmp/work", + "--model", "composer-1.5", + } + + 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.Errorf("args[%d] = %q, want %q", i, args[i], want) + } + } +} + +func TestBuildCursorArgsWithResume(t *testing.T) { + t.Parallel() + + args := buildCursorArgs("continue", ExecOptions{ + ResumeSessionID: "sess-123", + }, slog.Default()) + + hasResume := false + for i, a := range args { + if a == "--resume" && i+1 < len(args) && args[i+1] == "sess-123" { + hasResume = true + } + } + if !hasResume { + t.Fatalf("expected --resume sess-123, got %v", args) + } +} + +func TestBuildCursorArgsMinimal(t *testing.T) { + t.Parallel() + + args := buildCursorArgs("hello", ExecOptions{}, slog.Default()) + expected := []string{"chat", "-p", "hello", "--output-format", "stream-json", "--yolo"} + + if len(args) != len(expected) { + t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args) + } +} + +func TestBuildCursorArgsIgnoresSystemPromptAndMaxTurns(t *testing.T) { + t.Parallel() + + // cursor-agent CLI does not support --system-prompt or --max-turns; + // verify they are NOT emitted even when set in ExecOptions. + args := buildCursorArgs("task", ExecOptions{ + SystemPrompt: "You are helpful", + MaxTurns: 5, + }, slog.Default()) + + for _, a := range args { + if a == "--system-prompt" { + t.Fatalf("unexpected --system-prompt in args: %v", args) + } + if a == "--max-turns" { + t.Fatalf("unexpected --max-turns in args: %v", args) + } + } +} + +func TestBuildCursorArgsCustomArgs(t *testing.T) { + t.Parallel() + + args := buildCursorArgs("task", ExecOptions{ + CustomArgs: []string{"--extra", "val", "--yolo", "--output-format", "text"}, + }, slog.Default()) + + // --extra val should be present; --yolo and --output-format should be filtered out + hasExtra := false + hasBlockedYolo := false + hasBlockedFormat := false + for i, a := range args { + if a == "--extra" && i+1 < len(args) && args[i+1] == "val" { + hasExtra = true + } + } + // Count occurrences of --yolo (should be exactly 1 — the hardcoded one) + yoloCount := 0 + for _, a := range args { + if a == "--yolo" { + yoloCount++ + } + if a == "text" { + hasBlockedFormat = true + } + } + if yoloCount > 1 { + hasBlockedYolo = true + } + if !hasExtra { + t.Fatalf("expected --extra val in args, got %v", args) + } + if hasBlockedYolo { + t.Fatalf("--yolo from custom args should be filtered, got %v", args) + } + if hasBlockedFormat { + t.Fatalf("--output-format from custom args should be filtered, got %v", args) + } +} + +func TestNormalizeCursorStreamLine(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want string + }{ + {`stdout: {"type":"init"}`, `{"type":"init"}`}, + {`stderr: {"type":"error"}`, `{"type":"error"}`}, + {`stdout:{"type":"init"}`, `{"type":"init"}`}, + {` {"type":"assistant"} `, `{"type":"assistant"}`}, + {``, ``}, + {` `, ``}, + {`plain text`, `plain text`}, + } + + for _, tc := range tests { + got := normalizeCursorStreamLine(tc.input) + if got != tc.want { + t.Errorf("normalizeCursorStreamLine(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestCursorHandleAssistantText(t *testing.T) { + t.Parallel() + + b := &cursorBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 10) + var output strings.Builder + + evt := &cursorStreamEvent{ + Type: "assistant", + Message: mustMarshal(t, cursorAssistantMessage{ + Model: "composer-1.5", + Content: []cursorContentBlock{ + {Type: "output_text", Text: "Hello from Cursor"}, + }, + Usage: &cursorUsage{ + InputTokens: 100, + OutputTokens: 50, + }, + }), + } + + b.handleCursorAssistant(evt, ch, &output) + + if output.String() != "Hello from Cursor" { + t.Fatalf("expected output 'Hello from Cursor', got %q", output.String()) + } + + select { + case m := <-ch: + if m.Type != MessageText || m.Content != "Hello from Cursor" { + t.Fatalf("unexpected message: %+v", m) + } + default: + t.Fatal("expected message on channel") + } +} + +func TestCursorHandleAssistantToolUse(t *testing.T) { + t.Parallel() + + b := &cursorBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 10) + var output strings.Builder + + evt := &cursorStreamEvent{ + Type: "assistant", + Message: mustMarshal(t, cursorAssistantMessage{ + Content: []cursorContentBlock{ + { + Type: "tool_use", + ID: "call-42", + Name: "file_edit", + Input: mustMarshal(t, map[string]any{"path": "/tmp/foo.go"}), + }, + }, + }), + } + + b.handleCursorAssistant(evt, ch, &output) + + select { + case m := <-ch: + if m.Type != MessageToolUse || m.Tool != "file_edit" || m.CallID != "call-42" { + t.Fatalf("unexpected message: %+v", m) + } + default: + t.Fatal("expected message on channel") + } +} + +func TestCursorErrorText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + evt cursorStreamEvent + want string + }{ + {"error field", cursorStreamEvent{ErrorMsg: "bad request"}, "bad request"}, + {"detail field", cursorStreamEvent{Detail: "not found"}, "not found"}, + {"result field", cursorStreamEvent{ResultText: "failed"}, "failed"}, + {"empty", cursorStreamEvent{}, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := cursorErrorText(&tc.evt) + if got != tc.want { + t.Errorf("cursorErrorText = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCursorAccumulateResultUsage(t *testing.T) { + t.Parallel() + + b := &cursorBackend{cfg: Config{Logger: slog.Default()}} + usage := make(map[string]TokenUsage) + + evt := &cursorStreamEvent{ + Model: "gpt-5.3", + Usage: &cursorUsage{ + InputTokens: 200, + OutputTokens: 100, + CacheReadInputTokens: 50, + }, + } + + b.accumulateResultUsage(usage, evt) + + u := usage["gpt-5.3"] + if u.InputTokens != 200 || u.OutputTokens != 100 || u.CacheReadTokens != 50 { + t.Fatalf("unexpected usage: %+v", u) + } +} + +func TestCursorUsageOnlyFromResult(t *testing.T) { + t.Parallel() + + b := &cursorBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 10) + var output strings.Builder + + evt := &cursorStreamEvent{ + Type: "assistant", + Message: mustMarshal(t, cursorAssistantMessage{ + Model: "gpt-5", + Content: []cursorContentBlock{ + {Type: "text", Text: "hello"}, + }, + Usage: &cursorUsage{ + InputTokens: 999, + OutputTokens: 888, + }, + }), + } + + b.handleCursorAssistant(evt, ch, &output) + + if output.String() != "hello" { + t.Fatalf("expected 'hello', got %q", output.String()) + } + + // handleCursorAssistant should NOT have accumulated usage anywhere — + // usage is only taken from result events to avoid double-counting. + // (no usage map to check; this test documents the intent) +} + +func TestCursorStepFinishParsing(t *testing.T) { + t.Parallel() + + part := cursorStepFinishPart{} + data := `{"tokens":{"input":500,"output":200,"cache":{"read":100}},"cost":0.01}` + if err := json.Unmarshal([]byte(data), &part); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if part.Tokens.Input != 500 || part.Tokens.Output != 200 || part.Tokens.Cache.Read != 100 { + t.Fatalf("unexpected part: %+v", part) + } +} + +// TestCursorUsageNoDoubleCount verifies that step_finish and result usage +// are never double-counted. When a result event includes usage (session +// totals), step_finish values must be discarded entirely. +func TestCursorUsageNoDoubleCount(t *testing.T) { + t.Parallel() + + type jsonlEvent struct { + raw string + } + + tests := []struct { + name string + lines []string + want map[string]TokenUsage + }{ + { + name: "result_only — use result usage", + lines: []string{ + `{"type":"result","model":"gpt-5","usage":{"input_tokens":1000,"output_tokens":500,"cached_input_tokens":200}}`, + }, + want: map[string]TokenUsage{ + "gpt-5": {InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 200}, + }, + }, + { + name: "step_finish_only — fallback to step usage", + lines: []string{ + `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":300,"output":100,"cache":{"read":50}}}}`, + `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":200,"output":80,"cache":{"read":30}}}}`, + `{"type":"result","model":"gpt-5"}`, + }, + want: map[string]TokenUsage{ + "gpt-5": {InputTokens: 500, OutputTokens: 180, CacheReadTokens: 80}, + }, + }, + { + name: "step_finish_then_result — result wins, no double count", + lines: []string{ + `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":300,"output":100,"cache":{"read":50}}}}`, + `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":200,"output":80,"cache":{"read":30}}}}`, + `{"type":"result","model":"gpt-5","usage":{"input_tokens":500,"output_tokens":180,"cached_input_tokens":80}}`, + }, + want: map[string]TokenUsage{ + "gpt-5": {InputTokens: 500, OutputTokens: 180, CacheReadTokens: 80}, + }, + }, + { + name: "multi_model — each model tracked independently", + lines: []string{ + `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":100,"output":50,"cache":{"read":10}}}}`, + `{"type":"step_finish","model":"sonnet-4","part":{"tokens":{"input":200,"output":80,"cache":{"read":20}}}}`, + `{"type":"result","model":"gpt-5","usage":{"input_tokens":100,"output_tokens":50,"cached_input_tokens":10}}`, + }, + want: map[string]TokenUsage{ + // result had usage → use result only, discard all step_finish + "gpt-5": {InputTokens: 100, OutputTokens: 50, CacheReadTokens: 10}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stepUsage := make(map[string]TokenUsage) + resultUsage := make(map[string]TokenUsage) + hasResultUsage := false + + b := &cursorBackend{cfg: Config{Logger: slog.Default()}} + + for _, line := range tc.lines { + var evt cursorStreamEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + t.Fatalf("unmarshal %q: %v", line, err) + } + + switch evt.Type { + case "result": + b.accumulateResultUsage(resultUsage, &evt) + if evt.Usage != nil { + hasResultUsage = true + } + case "step_finish": + if evt.Part != nil { + var part cursorStepFinishPart + _ = json.Unmarshal(evt.Part, &part) + model := evt.Model + if model == "" { + model = "cursor" + } + u := stepUsage[model] + u.InputTokens += int64(part.Tokens.Input) + u.OutputTokens += int64(part.Tokens.Output) + u.CacheReadTokens += int64(part.Tokens.Cache.Read) + stepUsage[model] = u + } + } + } + + if !hasResultUsage { + resultUsage = stepUsage + } + + if len(resultUsage) != len(tc.want) { + t.Fatalf("got %d models, want %d: %+v", len(resultUsage), len(tc.want), resultUsage) + } + for model, want := range tc.want { + got := resultUsage[model] + if got != want { + t.Errorf("model %q: got %+v, want %+v", model, got, want) + } + } + }) + } +}