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