From 5a2a4a69ab428cb5d3e38fc7921bd92426369dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bobo=20H=C3=A4ggstr=C3=B6m?= <444567+bobobobo@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:31:37 +0100 Subject: [PATCH] feat: add cursor agent support --- cmd/chief/main.go | 7 +- .../theme/components/AgentSupport.vue | 1 + docs/concepts/prd-format.md | 2 +- docs/guide/installation.md | 9 + docs/guide/quick-start.md | 2 + docs/reference/configuration.md | 17 +- docs/troubleshooting/common-issues.md | 17 +- internal/agent/cursor.go | 113 +++++ internal/agent/cursor_test.go | 156 +++++++ internal/agent/resolve.go | 4 +- internal/agent/resolve_test.go | 14 + internal/config/config.go | 4 +- internal/loop/cursor_parser.go | 425 ++++++++++++++++++ internal/loop/cursor_parser_test.go | 325 ++++++++++++++ 14 files changed, 1080 insertions(+), 16 deletions(-) create mode 100644 internal/agent/cursor.go create mode 100644 internal/agent/cursor_test.go create mode 100644 internal/loop/cursor_parser.go create mode 100644 internal/loop/cursor_parser_test.go diff --git a/cmd/chief/main.go b/cmd/chief/main.go index 2de81bf..0045d5f 100644 --- a/cmd/chief/main.go +++ b/cmd/chief/main.go @@ -28,7 +28,7 @@ type TUIOptions struct { Merge bool Force bool NoRetry bool - Agent string // --agent claude|codex + Agent string // --agent claude|codex|opencode|cursor AgentPath string // --agent-path } @@ -134,7 +134,7 @@ func parseAgentFlags(args []string, startIdx int) (agentName, agentPath string, i++ agentName = args[i] } else { - fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude, codex, or opencode)\n") + fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude, codex, opencode, or cursor)\n") os.Exit(1) } case strings.HasPrefix(arg, "--agent="): @@ -530,7 +530,7 @@ Commands: help Show this help message Global Options: - --agent Agent CLI to use: claude (default), codex, or opencode + --agent Agent CLI to use: claude (default), codex, opencode, or cursor --agent-path Custom path to agent CLI binary --max-iterations N, -n N Set maximum iterations (default: dynamic) --no-retry Disable auto-retry on agent crashes @@ -557,6 +557,7 @@ Examples: Launch auth PRD with 5 max iterations chief --verbose Launch with raw agent output visible chief --agent codex Use Codex CLI instead of Claude + chief --agent cursor Use Cursor CLI as agent chief new Create PRD in .chief/prds/main/ chief new auth Create PRD in .chief/prds/auth/ chief new auth "JWT authentication for REST API" diff --git a/docs/.vitepress/theme/components/AgentSupport.vue b/docs/.vitepress/theme/components/AgentSupport.vue index c2a1986..0a6c7db 100644 --- a/docs/.vitepress/theme/components/AgentSupport.vue +++ b/docs/.vitepress/theme/components/AgentSupport.vue @@ -3,6 +3,7 @@ const agents = [ { name: 'Claude Code', description: 'By Anthropic' }, { name: 'Codex CLI', description: 'By OpenAI' }, { name: 'OpenCode', description: 'Open source' }, + { name: 'Cursor CLI', description: 'By Cursor' }, ] diff --git a/docs/concepts/prd-format.md b/docs/concepts/prd-format.md index 5c79744..ecc7893 100644 --- a/docs/concepts/prd-format.md +++ b/docs/concepts/prd-format.md @@ -25,7 +25,7 @@ Each PRD lives in its own subdirectory inside `.chief/prds/`: - **`prd.md`** — Written by you. Provides context, background, and guidance. - **`prd.json`** — The source of truth. Chief reads, updates, and drives execution from this file. - **`progress.md`** — Written by the agent. Tracks what was done, what changed, and what was learned. -- **`claude.log`** (or `codex.log` / `opencode.log`) — Written by Chief. Raw output from the agent for debugging. +- **`claude.log`** (or `codex.log` / `opencode.log` / `cursor.log`) — Written by Chief. Raw output from the agent for debugging. ## prd.md — The Human-Readable File diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 78f826b..6a8ee0b 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -45,6 +45,15 @@ To use [OpenCode CLI](https://opencode.ai) as an alternative: 2. Ensure `opencode` is on your PATH, or set `agent.cliPath` in `.chief/config.yaml` (see [Configuration](/reference/configuration#agent)). 3. Run Chief with `chief --agent opencode` or set `CHIEF_AGENT=opencode`, or set `agent.provider: opencode` in `.chief/config.yaml`. +### Option D: Cursor CLI + +To use [Cursor CLI](https://cursor.com/docs/cli/overview) as the agent: + +1. Install Cursor CLI per the [official docs](https://cursor.com/docs/cli/overview) +2. Ensure `agent` is on your PATH, or set `agent.cliPath` in `.chief/config.yaml`. +3. Run `agent login` for authentication. +4. Run Chief with `chief --agent cursor` or set `CHIEF_AGENT=cursor`, or set `agent.provider: cursor` in `.chief/config.yaml`. + ### Optional: GitHub CLI (`gh`) If you want Chief to automatically create pull requests when a PRD completes, install the [GitHub CLI](https://cli.github.com/): diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index ed8a372..02ff4a3 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -18,6 +18,7 @@ Before you begin, make sure you have: - [Claude Code](https://github.com/anthropics/claude-code) (default) - [Codex CLI](https://developers.openai.com/codex/cli/reference) - [OpenCode CLI](https://opencode.ai/docs/) + - [Cursor CLI](https://cursor.com/docs/cli/overview) - A project you want to work on (or create a new one) ::: tip Verify your agent CLI is working @@ -25,6 +26,7 @@ Run the version command for your agent to confirm it's installed: - `claude --version` (Claude Code) - `codex --version` (Codex) - `opencode --version` (OpenCode) +- `agent --version` (Cursor CLI) ::: ## Step 1: Install Chief diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8c3d404..06297b0 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -14,7 +14,7 @@ Chief stores project-level settings in `.chief/config.yaml`. This file is create ```yaml agent: - provider: claude # or "codex" or "opencode" + provider: claude # or "codex", "opencode", or "cursor" cliPath: "" # optional path to CLI binary worktree: setup: "npm install" @@ -27,7 +27,7 @@ onComplete: | Key | Type | Default | Description | |-----|------|---------|-------------| -| `agent.provider` | string | `"claude"` | Agent CLI to use: `claude`, `codex`, or `opencode` | +| `agent.provider` | string | `"claude"` | Agent CLI to use: `claude`, `codex`, `opencode`, or `cursor` | | `agent.cliPath` | string | `""` | Optional path to the agent binary (e.g. `/usr/local/bin/opencode`). If empty, Chief uses the provider name from PATH. | | `worktree.setup` | string | `""` | Shell command to run in new worktrees (e.g., `npm install`, `go mod download`) | | `onComplete.push` | bool | `false` | Automatically push the branch to remote when a PRD completes | @@ -88,7 +88,7 @@ These settings are saved to `.chief/config.yaml` and can be changed at any time | Flag | Description | Default | |------|-------------|---------| -| `--agent ` | Agent CLI to use: `claude`, `codex`, or `opencode` | From config / env / `claude` | +| `--agent ` | Agent CLI to use: `claude`, `codex`, `opencode`, or `cursor` | From config / env / `claude` | | `--agent-path ` | Custom path to the agent CLI binary | From config / env | | `--max-iterations `, `-n` | Loop iteration limit | Dynamic | | `--no-retry` | Disable auto-retry on agent crashes | `false` | @@ -102,7 +102,7 @@ When `--max-iterations` is not specified, Chief calculates a dynamic limit based ## Agent -Chief can use **Claude Code** (default), **Codex CLI**, or **OpenCode CLI** as the agent. Choose via: +Chief can use **Claude Code** (default), **Codex CLI**, **OpenCode CLI**, or **Cursor CLI** as the agent. Choose via: - **Config:** `agent.provider: opencode` and optionally `agent.cliPath: /path/to/opencode` in `.chief/config.yaml` - **Environment:** `CHIEF_AGENT=opencode`, `CHIEF_AGENT_PATH=/path/to/opencode` @@ -122,6 +122,15 @@ claude config set model claude-3-opus-20240229 See [Claude Code documentation](https://github.com/anthropics/claude-code) for details. +When using Cursor CLI: + +```bash +# Authentication (or set CURSOR_API_KEY for headless) +agent login +``` + +Chief runs Cursor in headless mode with `--trust` and `--force` so it can modify files without prompts. See [Cursor CLI documentation](https://cursor.com/docs/cli/overview) for details. + ## Permission Handling Some agents (like Claude Code) ask for permission before executing bash commands, writing files, and making network requests. Chief automatically configures the agent for autonomous operation by disabling these prompts. diff --git a/docs/troubleshooting/common-issues.md b/docs/troubleshooting/common-issues.md index f50b1b3..34e2b6d 100644 --- a/docs/troubleshooting/common-issues.md +++ b/docs/troubleshooting/common-issues.md @@ -44,6 +44,13 @@ Error: OpenCode CLI not found in PATH. Install it or set agent.cliPath in .chief cliPath: /usr/local/bin/opencode ``` Verify with `opencode --version` (or your `cliPath`). +- **Cursor:** Install [Cursor CLI](https://cursor.com/docs/cli/overview) (`curl https://cursor.com/install -fsS | bash`) and ensure `agent` is in PATH, or set the path in config: + ```yaml + agent: + provider: cursor + cliPath: /path/to/agent + ``` + Run `agent login`. Verify with `agent --version` (or your `cliPath`). ## Permission Denied @@ -63,9 +70,9 @@ Chief automatically configures the agent for autonomous operation by disabling p **Solution:** -1. Check the agent log for errors (the log file matches your agent: `claude.log`, `codex.log`, or `opencode.log`): +1. Check the agent log for errors (the log file matches your agent: `claude.log`, `codex.log`, `opencode.log`, or `cursor.log`): ```bash - tail -100 .chief/prds/your-prd/claude.log # or codex.log / opencode.log + tail -100 .chief/prds/your-prd/claude.log # or codex.log / opencode.log / cursor.log ``` 2. Manually mark story complete if appropriate: @@ -89,7 +96,7 @@ Chief automatically configures the agent for autonomous operation by disabling p 1. Check the agent log for what the agent is doing: ```bash - tail -f .chief/prds/your-prd/claude.log # or codex.log / opencode.log + tail -f .chief/prds/your-prd/claude.log # or codex.log / opencode.log / cursor.log ``` 2. Simplify the current story's acceptance criteria @@ -118,7 +125,7 @@ Chief automatically configures the agent for autonomous operation by disabling p 2. Or investigate why it's taking so many iterations: - Story too complex? Split it - - Stuck in a loop? Check the agent log (`claude.log`, `codex.log`, or `opencode.log`) + - Stuck in a loop? Check the agent log (`claude.log`, `codex.log`, `opencode.log`, or `cursor.log`) - Unclear acceptance criteria? Clarify them ## "No PRD Found" @@ -260,4 +267,4 @@ If none of these solutions help: 3. Open a new issue with: - Chief version (`chief --version`) - Your `prd.json` (sanitized) - - Relevant agent log excerpts (e.g. `claude.log`, `codex.log`, or `opencode.log`) + - Relevant agent log excerpts (e.g. `claude.log`, `codex.log`, `opencode.log`, or `cursor.log`) diff --git a/internal/agent/cursor.go b/internal/agent/cursor.go new file mode 100644 index 0000000..dc86e6a --- /dev/null +++ b/internal/agent/cursor.go @@ -0,0 +1,113 @@ +package agent + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + + "github.com/minicodemonkey/chief/internal/loop" +) + +// CursorProvider implements loop.Provider for the Cursor CLI (agent). +type CursorProvider struct { + cliPath string +} + +// NewCursorProvider returns a Provider for the Cursor CLI. +// If cliPath is empty, "agent" is used. +func NewCursorProvider(cliPath string) *CursorProvider { + if cliPath == "" { + cliPath = "agent" + } + return &CursorProvider{cliPath: cliPath} +} + +// Name implements loop.Provider. +func (p *CursorProvider) Name() string { return "Cursor" } + +// CLIPath implements loop.Provider. +func (p *CursorProvider) CLIPath() string { return p.cliPath } + +// LoopCommand implements loop.Provider. +// Prompt is supplied via stdin; Cursor CLI reads it when -p has no argument. +func (p *CursorProvider) LoopCommand(ctx context.Context, prompt, workDir string) *exec.Cmd { + cmd := exec.CommandContext(ctx, p.cliPath, + "-p", + "--output-format", "stream-json", + "--force", + "--workspace", workDir, + "--trust", + ) + cmd.Dir = workDir + cmd.Stdin = strings.NewReader(prompt) + return cmd +} + +// InteractiveCommand implements loop.Provider. +func (p *CursorProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { + cmd := exec.Command(p.cliPath, prompt) + cmd.Dir = workDir + return cmd +} + +// ConvertCommand implements loop.Provider. +// Prompt is supplied via stdin. +func (p *CursorProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string, error) { + cmd := exec.Command(p.cliPath, "-p", "--output-format", "text", "--workspace", workDir) + cmd.Dir = workDir + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputStdout, "", nil +} + +// FixJSONCommand implements loop.Provider. +// Prompt is supplied via stdin. +func (p *CursorProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string, error) { + cmd := exec.Command(p.cliPath, "-p", "--output-format", "text") + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputStdout, "", nil +} + +// ParseLine implements loop.Provider. +func (p *CursorProvider) ParseLine(line string) *loop.Event { + return loop.ParseLineCursor(line) +} + +// LogFileName implements loop.Provider. +func (p *CursorProvider) LogFileName() string { return "cursor.log" } + +// CleanOutput extracts the result from Cursor's json or stream-json output. +// For stream-json, finds the last type "result", subtype "success" and returns its result field. +// For single-line json, parses and returns result. +func (p *CursorProvider) CleanOutput(output string) string { + output = strings.TrimSpace(output) + if output == "" { + return output + } + // Try single JSON object (json output format) + var single struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + Result string `json:"result,omitempty"` + } + if json.Unmarshal([]byte(output), &single) == nil && single.Type == "result" && single.Subtype == "success" && single.Result != "" { + return single.Result + } + // NDJSON: find last result/success line + lines := strings.Split(output, "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + var ev struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + Result string `json:"result,omitempty"` + } + if json.Unmarshal([]byte(line), &ev) == nil && ev.Type == "result" && ev.Subtype == "success" && ev.Result != "" { + return ev.Result + } + } + return output +} diff --git a/internal/agent/cursor_test.go b/internal/agent/cursor_test.go new file mode 100644 index 0000000..90e8308 --- /dev/null +++ b/internal/agent/cursor_test.go @@ -0,0 +1,156 @@ +package agent + +import ( + "context" + "testing" + + "github.com/minicodemonkey/chief/internal/loop" +) + +func TestCursorProvider_Name(t *testing.T) { + p := NewCursorProvider("") + if p.Name() != "Cursor" { + t.Errorf("Name() = %q, want Cursor", p.Name()) + } +} + +func TestCursorProvider_CLIPath(t *testing.T) { + p := NewCursorProvider("") + if p.CLIPath() != "agent" { + t.Errorf("CLIPath() empty arg = %q, want agent", p.CLIPath()) + } + p2 := NewCursorProvider("/usr/local/bin/agent") + if p2.CLIPath() != "/usr/local/bin/agent" { + t.Errorf("CLIPath() custom = %q, want /usr/local/bin/agent", p2.CLIPath()) + } +} + +func TestCursorProvider_LogFileName(t *testing.T) { + p := NewCursorProvider("") + if p.LogFileName() != "cursor.log" { + t.Errorf("LogFileName() = %q, want cursor.log", p.LogFileName()) + } +} + +func TestCursorProvider_LoopCommand(t *testing.T) { + ctx := context.Background() + p := NewCursorProvider("/bin/agent") + cmd := p.LoopCommand(ctx, "hello world", "/work/dir") + + if cmd.Path != "/bin/agent" { + t.Errorf("LoopCommand Path = %q, want /bin/agent", cmd.Path) + } + wantArgs := []string{"/bin/agent", "-p", "--output-format", "stream-json", "--force", "--workspace", "/work/dir", "--trust"} + if len(cmd.Args) != len(wantArgs) { + t.Fatalf("LoopCommand Args len = %d, want %d: %v", len(cmd.Args), len(wantArgs), cmd.Args) + } + for i, w := range wantArgs { + if cmd.Args[i] != w { + t.Errorf("LoopCommand Args[%d] = %q, want %q", i, cmd.Args[i], w) + } + } + if cmd.Dir != "/work/dir" { + t.Errorf("LoopCommand Dir = %q, want /work/dir", cmd.Dir) + } + if cmd.Stdin == nil { + t.Error("LoopCommand Stdin must be set (prompt via stdin)") + } +} + +func TestCursorProvider_ConvertCommand(t *testing.T) { + p := NewCursorProvider("/bin/agent") + cmd, mode, outPath, err := p.ConvertCommand("/prd/dir", "convert prompt") + if err != nil { + t.Fatalf("ConvertCommand unexpected error: %v", err) + } + if mode != loop.OutputStdout { + t.Errorf("ConvertCommand mode = %v, want OutputStdout", mode) + } + if outPath != "" { + t.Errorf("ConvertCommand outPath = %q, want empty string", outPath) + } + if cmd.Dir != "/prd/dir" { + t.Errorf("ConvertCommand Dir = %q, want /prd/dir", cmd.Dir) + } + wantArgs := []string{"/bin/agent", "-p", "--output-format", "text", "--workspace", "/prd/dir"} + if len(cmd.Args) != len(wantArgs) { + t.Fatalf("ConvertCommand Args = %v, want %v", cmd.Args, wantArgs) + } + for i, w := range wantArgs { + if cmd.Args[i] != w { + t.Errorf("ConvertCommand Args[%d] = %q, want %q", i, cmd.Args[i], w) + } + } + if cmd.Stdin == nil { + t.Error("ConvertCommand Stdin must be set (prompt via stdin)") + } +} + +func TestCursorProvider_FixJSONCommand(t *testing.T) { + p := NewCursorProvider("/bin/agent") + cmd, mode, outPath, err := p.FixJSONCommand("fix prompt") + if err != nil { + t.Fatalf("FixJSONCommand unexpected error: %v", err) + } + if mode != loop.OutputStdout { + t.Errorf("FixJSONCommand mode = %v, want OutputStdout", mode) + } + if outPath != "" { + t.Errorf("FixJSONCommand outPath = %q, want empty string", outPath) + } + wantArgs := []string{"/bin/agent", "-p", "--output-format", "text"} + if len(cmd.Args) != len(wantArgs) { + t.Fatalf("FixJSONCommand Args = %v, want %v", cmd.Args, wantArgs) + } + for i, w := range wantArgs { + if cmd.Args[i] != w { + t.Errorf("FixJSONCommand Args[%d] = %q, want %q", i, cmd.Args[i], w) + } + } + if cmd.Stdin == nil { + t.Error("FixJSONCommand Stdin must be set (prompt via stdin)") + } +} + +func TestCursorProvider_InteractiveCommand(t *testing.T) { + p := NewCursorProvider("/bin/agent") + cmd := p.InteractiveCommand("/work", "my prompt") + if cmd.Dir != "/work" { + t.Errorf("InteractiveCommand Dir = %q, want /work", cmd.Dir) + } + if len(cmd.Args) != 2 || cmd.Args[0] != "/bin/agent" || cmd.Args[1] != "my prompt" { + t.Errorf("InteractiveCommand Args = %v, want [/bin/agent my prompt]", cmd.Args) + } +} + +func TestCursorProvider_ParseLine(t *testing.T) { + p := NewCursorProvider("") + line := `{"type":"system","subtype":"init","session_id":"x"}` + e := p.ParseLine(line) + if e == nil { + t.Fatal("ParseLine(system init) returned nil") + } + if e.Type != loop.EventIterationStart { + t.Errorf("ParseLine(system init) Type = %v, want EventIterationStart", e.Type) + } +} + +func TestCursorProvider_CleanOutput(t *testing.T) { + p := NewCursorProvider("") + // NDJSON: last result/success + ndjson := `{"type":"system","subtype":"init"} +{"type":"result","subtype":"success","result":"final answer","session_id":"x"}` + if got := p.CleanOutput(ndjson); got != "final answer" { + t.Errorf("CleanOutput(NDJSON) = %q, want final answer", got) + } + // Single JSON result + single := `{"type":"result","subtype":"success","result":"single result","session_id":"x"}` + if got := p.CleanOutput(single); got != "single result" { + t.Errorf("CleanOutput(single JSON) = %q, want single result", got) + } + // No result: return as-is + plain := "plain text" + if got := p.CleanOutput(plain); got != plain { + t.Errorf("CleanOutput(plain) = %q, want %q", got, plain) + } +} diff --git a/internal/agent/resolve.go b/internal/agent/resolve.go index c2649c1..2458740 100644 --- a/internal/agent/resolve.go +++ b/internal/agent/resolve.go @@ -39,8 +39,10 @@ func Resolve(flagAgent, flagPath string, cfg *config.Config) (loop.Provider, err return NewCodexProvider(cliPath), nil case "opencode": return NewOpenCodeProvider(cliPath), nil + case "cursor": + return NewCursorProvider(cliPath), nil default: - return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", or \"opencode\"", providerName) + return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", \"opencode\", or \"cursor\"", providerName) } } diff --git a/internal/agent/resolve_test.go b/internal/agent/resolve_test.go index fa199f8..32796f1 100644 --- a/internal/agent/resolve_test.go +++ b/internal/agent/resolve_test.go @@ -136,6 +136,20 @@ func TestResolve_opencode(t *testing.T) { } } +func TestResolve_cursor(t *testing.T) { + got := mustResolve(t, "cursor", "", nil) + if got.Name() != "Cursor" { + t.Errorf("Resolve(cursor) name = %q, want Cursor", got.Name()) + } + if got.CLIPath() != "agent" { + t.Errorf("Resolve(cursor) CLIPath = %q, want agent", got.CLIPath()) + } + got = mustResolve(t, "cursor", "/usr/local/bin/agent", nil) + if got.CLIPath() != "/usr/local/bin/agent" { + t.Errorf("Resolve(cursor, path) CLIPath = %q, want /usr/local/bin/agent", got.CLIPath()) + } +} + func TestResolve_unknownProvider(t *testing.T) { _, err := Resolve("typo", "", nil) if err == nil { diff --git a/internal/config/config.go b/internal/config/config.go index 6bad1f9..4523b1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,9 +16,9 @@ type Config struct { Agent AgentConfig `yaml:"agent"` } -// AgentConfig holds agent CLI settings (Claude, Codex, or OpenCode). +// AgentConfig holds agent CLI settings (Claude, Codex, OpenCode, or Cursor). type AgentConfig struct { - Provider string `yaml:"provider"` // "claude" (default) | "codex" | "opencode" + Provider string `yaml:"provider"` // "claude" (default) | "codex" | "opencode" | "cursor" CLIPath string `yaml:"cliPath"` // optional custom path to CLI binary } diff --git a/internal/loop/cursor_parser.go b/internal/loop/cursor_parser.go new file mode 100644 index 0000000..0e16484 --- /dev/null +++ b/internal/loop/cursor_parser.go @@ -0,0 +1,425 @@ +package loop + +import ( + "encoding/json" + "fmt" + "strings" +) + +// cursorEvent represents the top-level structure of Cursor CLI stream-json NDJSON. +type cursorEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + Message json.RawMessage `json:"message,omitempty"` + ToolCall json.RawMessage `json:"tool_call,omitempty"` +} + +// cursorAssistantMessage is the message body for type "assistant". +type cursorAssistantMessage struct { + Role string `json:"role"` + Content []cursorContentBlock `json:"content"` +} + +// cursorContentBlock is a content block in an assistant message. +type cursorContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +// cursorToolCall is the tool_call object. +type cursorToolCall struct { + ReadToolCall *cursorReadToolCall `json:"readToolCall,omitempty"` + WriteToolCall *cursorWriteToolCall `json:"writeToolCall,omitempty"` + EditToolCall *cursorEditToolCall `json:"editToolCall,omitempty"` + ShellToolCall *cursorShellToolCall `json:"shellToolCall,omitempty"` + GrepToolCall *cursorGrepToolCall `json:"grepToolCall,omitempty"` + GlobToolCall *cursorGlobToolCall `json:"globToolCall,omitempty"` + LsToolCall *cursorLsToolCall `json:"lsToolCall,omitempty"` + DeleteToolCall *cursorDeleteToolCall `json:"deleteToolCall,omitempty"` + WebFetchToolCall *cursorWebFetchToolCall `json:"webFetchToolCall,omitempty"` + WebSearchToolCall *cursorWebSearchToolCall `json:"webSearchToolCall,omitempty"` + Function *cursorFunctionCall `json:"function,omitempty"` +} + +// cursorWebFetchToolCall holds web fetch args and optional result. +type cursorWebFetchToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct { + URL string `json:"url"` + Markdown string `json:"markdown"` + } `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorWebSearchToolCall holds web search args and optional result. +type cursorWebSearchToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct { + References []struct { + Title string `json:"title"` + URL string `json:"url"` + Chunk string `json:"chunk"` + } `json:"references,omitempty"` + } `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorEditToolCall holds edit/strreplace args and optional result. +type cursorEditToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct{} `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorShellToolCall holds shell command args and optional result. +type cursorShellToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct { + ExitCode *int `json:"exitCode,omitempty"` + Output string `json:"output,omitempty"` + } `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorGrepToolCall holds grep args and optional result. +type cursorGrepToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct { + WorkspaceResults map[string]struct { + Content struct { + TotalMatchedLines int `json:"totalMatchedLines"` + } `json:"content"` + } `json:"workspaceResults,omitempty"` + } `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorGlobToolCall holds glob args and optional result. +type cursorGlobToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct { + TotalFiles int `json:"totalFiles"` + } `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorLsToolCall holds list-directory args and optional result. +type cursorLsToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct{} `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorDeleteToolCall holds delete-file args and optional result. +type cursorDeleteToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct{} `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorReadToolCall holds read file args and optional result. +type cursorReadToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct { + Content string `json:"content"` + } `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorWriteToolCall holds write file args and optional result. +type cursorWriteToolCall struct { + Args map[string]interface{} `json:"args,omitempty"` + Result *struct { + Success *struct { + Path string `json:"path"` + LinesCreated int `json:"linesCreated"` + FileSize int `json:"fileSize"` + } `json:"success,omitempty"` + } `json:"result,omitempty"` +} + +// cursorFunctionCall holds generic function name, arguments, and optional result. +type cursorFunctionCall struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +// ParseLineCursor parses a single line of Cursor CLI stream-json NDJSON and returns an Event. +// If the line cannot be parsed or is not relevant, it returns nil. +func ParseLineCursor(line string) *Event { + line = strings.TrimSpace(line) + if line == "" { + return nil + } + + var ev cursorEvent + if err := json.Unmarshal([]byte(line), &ev); err != nil { + return nil + } + + switch ev.Type { + case "system": + if ev.Subtype == "init" { + return &Event{Type: EventIterationStart} + } + return nil + + case "assistant": + return parseCursorAssistantMessage(ev.Message) + + case "tool_call": + return parseCursorToolCall(ev.Subtype, ev.ToolCall) + + case "user", "result": + return nil + + default: + return nil + } +} + +func parseCursorAssistantMessage(raw json.RawMessage) *Event { + if raw == nil { + return nil + } + var msg cursorAssistantMessage + if err := json.Unmarshal(raw, &msg); err != nil { + return nil + } + for _, block := range msg.Content { + if block.Type != "text" { + continue + } + text := block.Text + if strings.Contains(text, "") { + return &Event{Type: EventComplete, Text: text} + } + if storyID := extractStoryID(text, "", ""); storyID != "" { + return &Event{ + Type: EventStoryStarted, + Text: text, + StoryID: storyID, + } + } + return &Event{Type: EventAssistantText, Text: text} + } + return nil +} + +func parseCursorToolCall(subtype string, raw json.RawMessage) *Event { + if raw == nil { + return nil + } + var tc cursorToolCall + if err := json.Unmarshal(raw, &tc); err != nil { + return nil + } + toolName, toolInput := cursorToolCallNameAndInput(&tc) + switch subtype { + case "started": + return &Event{Type: EventToolStart, Tool: toolName, ToolInput: toolInput} + case "completed": + text := cursorToolCallResultSummary(&tc) + return &Event{Type: EventToolResult, Tool: toolName, Text: text} + } + return nil +} + +// cursorToolCallNameAndInput returns display name (PascalCase for TUI icons) and optional ToolInput for the log. +func cursorToolCallNameAndInput(tc *cursorToolCall) (name string, input map[string]interface{}) { + if tc.ReadToolCall != nil { + input = make(map[string]interface{}) + if path, ok := tc.ReadToolCall.Args["path"].(string); ok { + input["file_path"] = path + } + return "Read", input + } + if tc.WriteToolCall != nil { + input = make(map[string]interface{}) + if path, ok := tc.WriteToolCall.Args["path"].(string); ok { + input["file_path"] = path + } + return "Write", input + } + if tc.EditToolCall != nil { + input = make(map[string]interface{}) + if path, ok := tc.EditToolCall.Args["path"].(string); ok { + input["file_path"] = path + } + return "Edit", input + } + if tc.ShellToolCall != nil { + input = make(map[string]interface{}) + if cmd, ok := tc.ShellToolCall.Args["command"].(string); ok { + input["command"] = cmd + } + return "Bash", input + } + if tc.GrepToolCall != nil { + input = make(map[string]interface{}) + if pattern, ok := tc.GrepToolCall.Args["pattern"].(string); ok { + input["pattern"] = pattern + } + if path, ok := tc.GrepToolCall.Args["path"].(string); ok { + input["path"] = path + } + return "Grep", input + } + if tc.GlobToolCall != nil { + input = make(map[string]interface{}) + if pattern, ok := tc.GlobToolCall.Args["globPattern"].(string); ok { + input["pattern"] = pattern + } + if dir, ok := tc.GlobToolCall.Args["targetDirectory"].(string); ok { + input["path"] = dir + } + return "Glob", input + } + if tc.LsToolCall != nil { + input = make(map[string]interface{}) + if path, ok := tc.LsToolCall.Args["path"].(string); ok { + input["path"] = path + } + return "List", input + } + if tc.DeleteToolCall != nil { + input = make(map[string]interface{}) + if path, ok := tc.DeleteToolCall.Args["path"].(string); ok { + input["file_path"] = path + } + return "Delete", input + } + if tc.WebFetchToolCall != nil { + input = make(map[string]interface{}) + if url, ok := tc.WebFetchToolCall.Args["url"].(string); ok { + input["url"] = url + } + return "WebFetch", input + } + if tc.WebSearchToolCall != nil { + input = make(map[string]interface{}) + if term, ok := tc.WebSearchToolCall.Args["searchTerm"].(string); ok { + input["query"] = term + } + return "WebSearch", input + } + if tc.Function != nil && tc.Function.Name != "" { + // TUI knows "Bash" for command execution; Cursor may use different names + name = tc.Function.Name + if name == "run_terminal_cmd" || name == "run_command" { + name = "Bash" + } + if tc.Function.Arguments != "" { + input = map[string]interface{}{"arguments": tc.Function.Arguments} + // Try to extract command for Bash display + var argsMap map[string]interface{} + if json.Unmarshal([]byte(tc.Function.Arguments), &argsMap) == nil { + if cmd, ok := argsMap["command"].(string); ok { + input["command"] = cmd + } + } + } + return name, input + } + return "tool", nil +} + +func cursorToolCallResultSummary(tc *cursorToolCall) string { + if tc.ReadToolCall != nil && tc.ReadToolCall.Result != nil && tc.ReadToolCall.Result.Success != nil { + return tc.ReadToolCall.Result.Success.Content + } + if tc.WriteToolCall != nil && tc.WriteToolCall.Result != nil && tc.WriteToolCall.Result.Success != nil { + s := tc.WriteToolCall.Result.Success + if s.Path != "" { + return s.Path + } + return "(written)" + } + if tc.EditToolCall != nil && tc.EditToolCall.Result != nil && tc.EditToolCall.Result.Success != nil { + return "(edited)" + } + if tc.ShellToolCall != nil && tc.ShellToolCall.Result != nil && tc.ShellToolCall.Result.Success != nil { + s := tc.ShellToolCall.Result.Success + if s.Output != "" { + return strings.TrimSpace(s.Output) + } + if s.ExitCode != nil { + return fmt.Sprintf("(exit %d)", *s.ExitCode) + } + return "(executed)" + } + if tc.GrepToolCall != nil && tc.GrepToolCall.Result != nil && tc.GrepToolCall.Result.Success != nil { + for _, v := range tc.GrepToolCall.Result.Success.WorkspaceResults { + return fmt.Sprintf("%d matches", v.Content.TotalMatchedLines) + } + return "(matches)" + } + if tc.GlobToolCall != nil && tc.GlobToolCall.Result != nil && tc.GlobToolCall.Result.Success != nil { + n := tc.GlobToolCall.Result.Success.TotalFiles + return fmt.Sprintf("%d files", n) + } + if tc.LsToolCall != nil && tc.LsToolCall.Result != nil && tc.LsToolCall.Result.Success != nil { + return "(listed)" + } + if tc.DeleteToolCall != nil && tc.DeleteToolCall.Result != nil && tc.DeleteToolCall.Result.Success != nil { + return "(deleted)" + } + if tc.WebFetchToolCall != nil && tc.WebFetchToolCall.Result != nil && tc.WebFetchToolCall.Result.Success != nil { + s := tc.WebFetchToolCall.Result.Success + if s.Markdown != "" { + return strings.TrimSpace(s.Markdown) + } + return "(fetched)" + } + if tc.WebSearchToolCall != nil && tc.WebSearchToolCall.Result != nil && tc.WebSearchToolCall.Result.Success != nil { + refs := tc.WebSearchToolCall.Result.Success.References + if len(refs) == 0 { + return "(no results)" + } + if len(refs) == 1 && refs[0].Chunk != "" { + return strings.TrimSpace(refs[0].Chunk) + } + return fmt.Sprintf("%d reference(s)", len(refs)) + } + if tc.Function != nil && len(tc.Function.Result) > 0 { + s := extractFunctionResultText(tc.Function.Result) + if s != "" { + return s + } + } + if tc.Function != nil { + return "(executed)" + } + return "" +} + +// extractFunctionResultText tries to get a short result string from Cursor function result JSON. +func extractFunctionResultText(raw json.RawMessage) string { + var m map[string]interface{} + if json.Unmarshal(raw, &m) != nil { + return "" + } + for _, key := range []string{"output", "content", "result", "stdout", "text"} { + if v, ok := m[key].(string); ok && v != "" { + return v + } + } + if success, ok := m["success"].(map[string]interface{}); ok { + for _, key := range []string{"output", "content", "result", "stdout"} { + if v, ok := success[key].(string); ok && v != "" { + return v + } + } + } + return "" +} diff --git a/internal/loop/cursor_parser_test.go b/internal/loop/cursor_parser_test.go new file mode 100644 index 0000000..4f03d52 --- /dev/null +++ b/internal/loop/cursor_parser_test.go @@ -0,0 +1,325 @@ +package loop + +import ( + "strings" + "testing" +) + +func TestParseLineCursor_systemInit(t *testing.T) { + line := `{"type":"system","subtype":"init","apiKeySource":"login","cwd":"/Users/user/project","session_id":"c6b62c6f-7ead-4fd6-9922-e952131177ff","model":"Claude 4 Sonnet","permissionMode":"default"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventIterationStart { + t.Errorf("expected EventIterationStart, got %v", ev.Type) + } +} + +func TestParseLineCursor_assistantText(t *testing.T) { + line := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I'll read the README.md file"}]},"session_id":"c6b62c6f-7ead-4fd6-9922-e952131177ff"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventAssistantText { + t.Errorf("expected EventAssistantText, got %v", ev.Type) + } + if ev.Text != "I'll read the README.md file" { + t.Errorf("expected Text, got %q", ev.Text) + } +} + +func TestParseLineCursor_chiefComplete(t *testing.T) { + line := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done. "}]},"session_id":"x"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventComplete { + t.Errorf("expected EventComplete, got %v", ev.Type) + } +} + +func TestParseLineCursor_ralphStatus(t *testing.T) { + line := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Working on story. story-1"}]},"session_id":"x"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventStoryStarted { + t.Errorf("expected EventStoryStarted, got %v", ev.Type) + } + if ev.StoryID != "story-1" { + t.Errorf("expected StoryID story-1, got %q", ev.StoryID) + } +} + +func TestParseLineCursor_toolCallStartedRead(t *testing.T) { + line := `{"type":"tool_call","subtype":"started","call_id":"toolu_abc","tool_call":{"readToolCall":{"args":{"path":"README.md"}}},"session_id":"x"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolStart { + t.Errorf("expected EventToolStart, got %v", ev.Type) + } + if ev.Tool != "Read" { + t.Errorf("expected Tool Read, got %q", ev.Tool) + } + if ev.ToolInput == nil || ev.ToolInput["file_path"] != "README.md" { + t.Errorf("expected ToolInput file_path=README.md, got %v", ev.ToolInput) + } +} + +func TestParseLineCursor_toolCallStartedWrite(t *testing.T) { + line := `{"type":"tool_call","subtype":"started","call_id":"toolu_xyz","tool_call":{"writeToolCall":{"args":{"path":"summary.txt","fileText":"content"}}},"session_id":"x"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolStart { + t.Errorf("expected EventToolStart, got %v", ev.Type) + } + if ev.Tool != "Write" { + t.Errorf("expected Tool Write, got %q", ev.Tool) + } + if ev.ToolInput == nil || ev.ToolInput["file_path"] != "summary.txt" { + t.Errorf("expected ToolInput file_path=summary.txt, got %v", ev.ToolInput) + } +} + +func TestParseLineCursor_toolCallCompletedRead(t *testing.T) { + line := `{"type":"tool_call","subtype":"completed","call_id":"toolu_abc","tool_call":{"readToolCall":{"args":{"path":"README.md"},"result":{"success":{"content":"# Project\n\nContent here.","isEmpty":false,"exceededLimit":false,"totalLines":10,"totalChars":100}}}},"session_id":"x"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev.Type) + } + if ev.Tool != "Read" { + t.Errorf("expected Tool Read, got %q", ev.Tool) + } + if ev.Text != "# Project\n\nContent here." { + t.Errorf("expected Text content, got %q", ev.Text) + } +} + +func TestParseLineCursor_toolCallCompletedWrite(t *testing.T) { + line := `{"type":"tool_call","subtype":"completed","call_id":"toolu_xyz","tool_call":{"writeToolCall":{"args":{"path":"summary.txt"},"result":{"success":{"path":"/Users/user/project/summary.txt","linesCreated":19,"fileSize":942}}}},"session_id":"x"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev.Type) + } + if ev.Tool != "Write" { + t.Errorf("expected Tool Write, got %q", ev.Tool) + } + if ev.Text != "/Users/user/project/summary.txt" { + t.Errorf("expected Text path, got %q", ev.Text) + } +} + +func TestParseLineCursor_toolCallFunctionWithResult(t *testing.T) { + line := `{"type":"tool_call","subtype":"completed","call_id":"toolu_fn","tool_call":{"function":{"name":"run_terminal_cmd","arguments":"{}","result":{"success":{"output":"hello world"}}}},"session_id":"x"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev.Type) + } + if ev.Text != "hello world" { + t.Errorf("expected Text from result.success.output, got %q", ev.Text) + } +} + +func TestParseLineCursor_toolCallFunctionNoResult(t *testing.T) { + line := `{"type":"tool_call","subtype":"completed","call_id":"toolu_fn2","tool_call":{"function":{"name":"some_tool","arguments":"{}"}},"session_id":"x"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev.Type) + } + if ev.Text != "(executed)" { + t.Errorf("expected Text (executed) when no result, got %q", ev.Text) + } +} + +func TestParseLineCursor_userAndResult_ignored(t *testing.T) { + userLine := `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"prompt"}]},"session_id":"x"}` + if ev := ParseLineCursor(userLine); ev != nil { + t.Errorf("user event expected nil, got %v", ev) + } + resultLine := `{"type":"result","subtype":"success","duration_ms":1234,"result":"done","session_id":"x"}` + if ev := ParseLineCursor(resultLine); ev != nil { + t.Errorf("result event expected nil, got %v", ev) + } +} + +func TestParseLineCursor_toolCallEditStartedAndCompleted(t *testing.T) { + started := `{"type":"tool_call","subtype":"started","call_id":"toolu_edit","tool_call":{"editToolCall":{"args":{"path":"index.html"}}},"session_id":"x"}` + ev := ParseLineCursor(started) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolStart { + t.Errorf("expected EventToolStart, got %v", ev.Type) + } + if ev.Tool != "Edit" { + t.Errorf("expected Tool Edit, got %q", ev.Tool) + } + if ev.ToolInput == nil || ev.ToolInput["file_path"] != "index.html" { + t.Errorf("expected ToolInput file_path=index.html, got %v", ev.ToolInput) + } + + completed := `{"type":"tool_call","subtype":"completed","call_id":"toolu_edit","tool_call":{"editToolCall":{"args":{"path":"index.html"},"result":{"success":{}}}},"session_id":"x"}` + ev2 := ParseLineCursor(completed) + if ev2 == nil { + t.Fatal("expected event, got nil") + } + if ev2.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev2.Type) + } + if ev2.Tool != "Edit" { + t.Errorf("expected Tool Edit, got %q", ev2.Tool) + } + if ev2.Text != "(edited)" { + t.Errorf("expected Text (edited), got %q", ev2.Text) + } +} + +func TestParseLineCursor_toolCallShellStartedAndCompleted(t *testing.T) { + started := `{"type":"tool_call","subtype":"started","call_id":"toolu_shell","tool_call":{"shellToolCall":{"args":{"command":"echo hello"}}},"session_id":"x"}` + ev := ParseLineCursor(started) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolStart { + t.Errorf("expected EventToolStart, got %v", ev.Type) + } + if ev.Tool != "Bash" { + t.Errorf("expected Tool Bash, got %q", ev.Tool) + } + if ev.ToolInput == nil || ev.ToolInput["command"] != "echo hello" { + t.Errorf("expected ToolInput command=echo hello, got %v", ev.ToolInput) + } + + completed := `{"type":"tool_call","subtype":"completed","call_id":"toolu_shell","tool_call":{"shellToolCall":{"args":{"command":"echo hello"},"result":{"success":{"exitCode":0,"output":"hello\n"}}}},"session_id":"x"}` + ev2 := ParseLineCursor(completed) + if ev2 == nil { + t.Fatal("expected event, got nil") + } + if ev2.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev2.Type) + } + if ev2.Tool != "Bash" { + t.Errorf("expected Tool Bash, got %q", ev2.Tool) + } + if ev2.Text != "hello" { + t.Errorf("expected Text hello, got %q", ev2.Text) + } +} + +func TestParseLineCursor_toolCallWebSearchStartedAndCompleted(t *testing.T) { + started := `{"type":"tool_call","subtype":"started","call_id":"toolu_01SFCs5FKmApRiaNPKy3BBqi","tool_call":{"webSearchToolCall":{"args":{"searchTerm":"place.horse API random horse image","toolCallId":"toolu_01SFCs5FKmApRiaNPKy3BBqi"}}},"session_id":"x"}` + ev := ParseLineCursor(started) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolStart { + t.Errorf("expected EventToolStart, got %v", ev.Type) + } + if ev.Tool != "WebSearch" { + t.Errorf("expected Tool WebSearch, got %q", ev.Tool) + } + if ev.ToolInput == nil || ev.ToolInput["query"] != "place.horse API random horse image" { + t.Errorf("expected ToolInput query=place.horse API random horse image, got %v", ev.ToolInput) + } + + completed := `{"type":"tool_call","subtype":"completed","call_id":"toolu_01SFCs5FKmApRiaNPKy3BBqi","tool_call":{"webSearchToolCall":{"args":{"searchTerm":"place.horse API random horse image","toolCallId":"toolu_01SFCs5FKmApRiaNPKy3BBqi"},"result":{"success":{"references":[{"title":"Web search results for query: place.horse API random horse image","url":"","chunk":"Links:\n1. [API docs](https://theponyapi.com/docs)\n2. [Lorem Picsum](http://picsum.photos/)\n\nBased on your query about a place.horse API..."}]}}}},"session_id":"x"}` + ev2 := ParseLineCursor(completed) + if ev2 == nil { + t.Fatal("expected event, got nil") + } + if ev2.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev2.Type) + } + if ev2.Tool != "WebSearch" { + t.Errorf("expected Tool WebSearch, got %q", ev2.Tool) + } + // Single reference with chunk: summary is the chunk text + if ev2.Text == "" { + t.Errorf("expected non-empty Text (chunk), got %q", ev2.Text) + } + if !strings.Contains(ev2.Text, "Based on your query") { + t.Errorf("expected Text to contain chunk content, got %q", ev2.Text) + } +} + +func TestParseLineCursor_toolCallWebSearchCompletedMultipleRefs(t *testing.T) { + line := `{"type":"tool_call","subtype":"completed","call_id":"toolu_x","tool_call":{"webSearchToolCall":{"result":{"success":{"references":[{"title":"A","url":"","chunk":""},{"title":"B","url":"","chunk":""}]}}}},"session_id":"x"}` + ev := ParseLineCursor(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev.Type) + } + if ev.Tool != "WebSearch" { + t.Errorf("expected Tool WebSearch, got %q", ev.Tool) + } + if ev.Text != "2 reference(s)" { + t.Errorf("expected Text 2 reference(s), got %q", ev.Text) + } +} + +func TestParseLineCursor_toolCallWebFetchStartedAndCompleted(t *testing.T) { + started := `{"type":"tool_call","subtype":"started","call_id":"toolu_01SALput2Tb7iCNqx4jfy8v2","tool_call":{"webFetchToolCall":{"args":{"url":"https://github.com/treboryx/animalsAPI","toolCallId":"toolu_01SALput2Tb7iCNqx4jfy8v2"}}},"session_id":"x"}` + ev := ParseLineCursor(started) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolStart { + t.Errorf("expected EventToolStart, got %v", ev.Type) + } + if ev.Tool != "WebFetch" { + t.Errorf("expected Tool WebFetch, got %q", ev.Tool) + } + if ev.ToolInput == nil || ev.ToolInput["url"] != "https://github.com/treboryx/animalsAPI" { + t.Errorf("expected ToolInput url=https://github.com/treboryx/animalsAPI, got %v", ev.ToolInput) + } + + completed := `{"type":"tool_call","subtype":"completed","call_id":"toolu_01SALput2Tb7iCNqx4jfy8v2","tool_call":{"webFetchToolCall":{"args":{"url":"https://github.com/treboryx/animalsAPI","toolCallId":"toolu_01SALput2Tb7iCNqx4jfy8v2"},"result":{"success":{"url":"https://github.com/treboryx/animalsAPI","markdown":"# treboryx/animalsAPI\n\nAll-in-one API for random animal images."}}}},"session_id":"x"}` + ev2 := ParseLineCursor(completed) + if ev2 == nil { + t.Fatal("expected event, got nil") + } + if ev2.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev2.Type) + } + if ev2.Tool != "WebFetch" { + t.Errorf("expected Tool WebFetch, got %q", ev2.Tool) + } + if ev2.Text == "" { + t.Errorf("expected non-empty Text (markdown), got %q", ev2.Text) + } + if !strings.Contains(ev2.Text, "treboryx/animalsAPI") { + t.Errorf("expected Text to contain markdown content, got %q", ev2.Text) + } +} + +func TestParseLineCursor_emptyOrInvalid_returnsNil(t *testing.T) { + tests := []string{"", " ", "not json", "{}", `{"type":"unknown"}`} + for _, line := range tests { + ev := ParseLineCursor(line) + if ev != nil { + t.Errorf("ParseLineCursor(%q) expected nil, got %v", line, ev) + } + } +}