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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
devv-eve
2026-04-16 00:54:21 -07:00
committed by GitHub
parent efb0c1dccf
commit c0b4e7e8b8
8 changed files with 891 additions and 7 deletions

View File

@@ -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)"

View File

@@ -88,6 +88,20 @@ function PiLogo({ className }: { className: string }) {
);
}
// Cursor — official brand logo from Cursor brand assets
function CursorLogo({ className }: { className: string }) {
return (
<svg viewBox="600 300 400 400" fill="none" className={className}>
<path fill="#14120B" d="M999.994 554.294C999.994 559.859 999.994 565.419 999.962 570.984C999.935 575.67 999.882 580.357 999.753 585.038C999.475 595.247 998.875 605.542 997.059 615.639C995.217 625.88 992.212 635.409 987.477 644.718C982.822 653.861 976.738 662.233 969.485 669.491C962.227 676.748 953.861 682.828 944.712 687.482C935.409 692.217 925.875 695.222 915.633 697.065C905.537 698.88 895.242 699.48 885.033 699.759C880.346 699.887 875.665 699.941 870.978 699.968C865.413 700.005 859.853 700 854.288 700H745.695C740.13 700 734.571 700 729.005 699.968C724.319 699.941 719.632 699.887 714.951 699.759C704.742 699.48 694.447 698.88 684.35 697.065C674.109 695.222 664.58 692.217 655.271 687.482C646.128 682.828 637.756 676.743 630.499 669.491C623.241 662.233 617.161 653.866 612.507 644.718C607.772 635.414 604.767 625.88 602.925 615.639C601.109 605.542 600.509 595.247 600.23 585.038C600.102 580.352 600.048 575.67 600.021 570.984C600 565.419 600 559.859 600 554.294V445.701C600 440.136 600 434.576 600.032 429.011C600.059 424.324 600.112 419.637 600.241 414.956C600.52 404.747 601.119 394.452 602.935 384.356C604.778 374.115 607.783 364.586 612.518 355.277C617.172 346.133 623.257 337.762 630.509 330.504C637.767 323.246 646.133 317.167 655.282 312.512C664.586 307.777 674.12 304.772 684.361 302.93C694.458 301.114 704.752 300.514 714.961 300.236C719.648 300.107 724.329 300.054 729.016 300.027C734.576 300 740.136 300 745.701 300H854.294C859.859 300 865.419 300 870.984 300.032C875.67 300.059 880.357 300.112 885.038 300.241C895.247 300.52 905.542 301.119 915.639 302.935C925.88 304.778 935.409 307.783 944.718 312.518C953.861 317.172 962.233 323.257 969.491 330.509C976.748 337.767 982.828 346.133 987.482 355.282C992.217 364.586 995.222 374.12 997.065 384.361C998.88 394.458 999.48 404.752 999.759 414.961C999.887 419.648 999.941 424.329 999.968 429.016C1000.01 434.581 1000 440.141 1000 445.706V554.299L999.994 554.294Z"/>
<path fill="#72716D" d="M800.004 500L923.821 571.486C923.061 572.804 921.957 573.929 920.591 574.716L804.863 641.531C801.858 643.266 798.151 643.266 795.146 641.531L679.417 574.716C678.052 573.929 676.948 572.804 676.188 571.486L800.004 500Z"/>
<path fill="#55544F" d="M800.005 357.168V500L676.188 571.486C675.427 570.168 675.004 568.647 675.004 567.072V432.928C675.004 429.774 676.686 426.865 679.418 425.285L795.141 358.47C796.646 357.602 798.323 357.168 799.999 357.168H800.005Z"/>
<path fill="#43413C" d="M923.815 428.515C923.055 427.197 921.951 426.072 920.586 425.285L804.857 358.47C803.357 357.602 801.68 357.168 800.004 357.168V500L923.821 571.486C924.581 570.168 925.005 568.647 925.005 567.072V432.928C925.005 431.348 924.587 429.838 923.821 428.515H923.815Z"/>
<path fill="#D6D5D2" d="M915.156 433.518C915.857 434.728 915.954 436.281 915.156 437.663L802.764 632.323C802.008 633.641 800 633.1 800 631.584V503.311C800 502.287 799.727 501.302 799.229 500.44L915.15 433.512H915.156V433.518Z"/>
<path fill="white" d="M915.155 433.518L799.233 500.445C798.741 499.588 798.023 498.86 797.134 498.345L686.049 434.209C684.731 433.453 685.272 431.445 686.788 431.445H911.566C913.162 431.445 914.459 432.307 915.155 433.518Z"/>
</svg>
);
}
export function ProviderLogo({
provider,
className = "h-4 w-4",
@@ -108,6 +122,8 @@ export function ProviderLogo({
return <HermesLogo className={className} />;
case "pi":
return <PiLogo className={className} />;
case "cursor":
return <CursorLogo className={className} />;
default:
return <Monitor className={className} />;
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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.

View File

@@ -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)
}
}

419
server/pkg/agent/cursor.go Normal file
View File

@@ -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, &params)
}
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 <prompt> --output-format stream-json
//
// --workspace <cwd> --yolo [--model <m>] [--resume <id>]
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
}

View File

@@ -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)
}
}
})
}
}