From e428ab41c0e179e8393fa4105455ac354bde526c Mon Sep 17 00:00:00 2001 From: Christian Widlund Date: Fri, 31 Jul 2026 20:24:55 +0200 Subject: [PATCH] feat: add GitHub Copilot CLI provider support - add Copilot provider with headless command mode and output cleanup\n- parse Copilot JSONL assistant and tool events in the loop\n- wire provider resolution for copilot and copilot-cli\n- expand docs and CLI help to include Copilot setup and usage\n\nRefs #57\nhttps://github.com/features/copilot/cli\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +- cmd/chief/main.go | 7 ++- docs/concepts/prd-format.md | 2 +- docs/guide/installation.md | 9 +++ docs/guide/quick-start.md | 4 ++ docs/reference/configuration.md | 8 +-- docs/troubleshooting/common-issues.md | 17 ++++-- internal/agent/copilot.go | 88 +++++++++++++++++++++++++++ internal/agent/copilot_test.go | 82 +++++++++++++++++++++++++ internal/agent/resolve.go | 4 +- internal/agent/resolve_test.go | 20 ++++++ internal/config/config.go | 4 +- internal/loop/copilot_parser.go | 77 +++++++++++++++++++++++ internal/loop/copilot_parser_test.go | 78 ++++++++++++++++++++++++ 14 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 internal/agent/copilot.go create mode 100644 internal/agent/copilot_test.go create mode 100644 internal/loop/copilot_parser.go create mode 100644 internal/loop/copilot_parser_test.go diff --git a/README.md b/README.md index 7472b794..65eb946a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ See the [documentation](https://minicodemonkey.github.io/chief/concepts/how-it-w ## Requirements -- **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)**, **[Codex CLI](https://developers.openai.com/codex/cli/reference)**, **[OpenCode CLI](https://opencode.ai)**, or **[Gemini CLI](https://github.com/google-gemini/gemini-cli)** installed and authenticated +- **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)**, **[Codex CLI](https://developers.openai.com/codex/cli/reference)**, **[OpenCode CLI](https://opencode.ai)**, **[Gemini CLI](https://github.com/google-gemini/gemini-cli)**, or **[GitHub Copilot CLI](https://github.com/features/copilot/cli)** installed and authenticated Use Claude by default, or configure another provider in `.chief/config.yaml`: @@ -56,7 +56,7 @@ agent: Or run with `chief --agent opencode` or set `CHIEF_AGENT=opencode`. -Supported values for `provider` are: `claude`, `codex`, `opencode`, `cursor`, and `gemini`. +Supported values for `provider` are: `claude`, `codex`, `opencode`, `cursor`, `gemini`, and `copilot`. ## License diff --git a/cmd/chief/main.go b/cmd/chief/main.go index 83abbb21..cfd2b695 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|opencode|cursor + Agent string // --agent claude|codex|opencode|cursor|gemini|copilot 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, opencode, cursor or gemini)\n") + fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude, codex, opencode, cursor, gemini or copilot)\n") os.Exit(1) } case strings.HasPrefix(arg, "--agent="): @@ -514,7 +514,7 @@ Commands: help Show this help message Global Options: - --agent Agent CLI to use: claude (default), codex, opencode, cursor, or gemini + --agent Agent CLI to use: claude (default), codex, opencode, cursor, gemini, or copilot --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 @@ -542,6 +542,7 @@ Examples: 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 --agent copilot Use GitHub Copilot 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/concepts/prd-format.md b/docs/concepts/prd-format.md index a0014c0a..b3b79f1a 100644 --- a/docs/concepts/prd-format.md +++ b/docs/concepts/prd-format.md @@ -23,7 +23,7 @@ Each PRD lives in its own subdirectory inside `.chief/prds/`: - **`prd.md`** — Written by you, read and updated by Chief. Contains project context and structured user stories. - **`progress.md`** — Written by the agent. Tracks what was done, what changed, and what was learned. -- **`claude.log`** (or `codex.log` / `opencode.log` / `cursor.log`) — Written by Chief. Raw output from the agent for debugging. +- **Agent log** (`claude.log`, `codex.log`, `opencode.log`, `cursor.log`, `gemini.log`, or `copilot.log`) — Written by Chief. Raw output from the agent for debugging. ## prd.md — The PRD File diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 6a8ee0b1..46e40075 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -54,6 +54,15 @@ To use [Cursor CLI](https://cursor.com/docs/cli/overview) as the agent: 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`. +### Option E: GitHub Copilot CLI + +To use [GitHub Copilot CLI](https://github.com/features/copilot/cli) as the agent: + +1. Install Copilot CLI using the [official instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli). +2. Ensure `copilot` is on your PATH, or set `agent.cliPath` in `.chief/config.yaml`. +3. Run `copilot login` for authentication. +4. Run Chief with `chief --agent copilot` or set `CHIEF_AGENT=copilot`, or set `agent.provider: copilot` 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 dad788b7..589be64c 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -19,6 +19,8 @@ Before you begin, make sure you have: - [Codex CLI](https://developers.openai.com/codex/cli/reference) - [OpenCode CLI](https://opencode.ai/docs/) - [Cursor CLI](https://cursor.com/docs/cli/overview) + - [Gemini CLI](https://github.com/google-gemini/gemini-cli) + - [GitHub Copilot CLI](https://github.com/features/copilot/cli) - A project you want to work on (or create a new one) ::: tip Verify your agent CLI is working @@ -27,6 +29,8 @@ Run the version command for your agent to confirm it's installed: - `codex --version` (Codex) - `opencode --version` (OpenCode) - `agent --version` (Cursor CLI) +- `gemini --version` (Gemini CLI) +- `copilot --version` (GitHub Copilot CLI) ::: ## Step 1: Install Chief diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1cb5f7b7..d22a51e4 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", "opencode", or "cursor" + provider: claude # or "codex", "opencode", "cursor", "gemini", or "copilot" 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`, `opencode`, or `cursor` | +| `agent.provider` | string | `"claude"` | Agent CLI to use: `claude`, `codex`, `opencode`, `cursor`, `gemini`, or `copilot` | | `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`, `opencode`, or `cursor` | From config / env / `claude` | +| `--agent ` | Agent CLI to use: `claude`, `codex`, `opencode`, `cursor`, `gemini`, or `copilot` | 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` | @@ -100,7 +100,7 @@ When `--max-iterations` is not specified, Chief calculates a dynamic limit based ## Agent -Chief can use **Claude Code** (default), **Codex CLI**, **OpenCode CLI**, **Cursor CLI**, or **Gemini CLI** as the agent. Choose via: +Chief can use **Claude Code** (default), **Codex CLI**, **OpenCode CLI**, **Cursor CLI**, **Gemini CLI**, or **GitHub Copilot 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` diff --git a/docs/troubleshooting/common-issues.md b/docs/troubleshooting/common-issues.md index 35e51310..6f92162f 100644 --- a/docs/troubleshooting/common-issues.md +++ b/docs/troubleshooting/common-issues.md @@ -51,6 +51,13 @@ Error: OpenCode CLI not found in PATH. Install it or set agent.cliPath in .chief cliPath: /path/to/agent ``` Run `agent login`. Verify with `agent --version` (or your `cliPath`). +- **GitHub Copilot:** Install [GitHub Copilot CLI](https://github.com/features/copilot/cli) and ensure `copilot` is in PATH, or set the path in config: + ```yaml + agent: + provider: copilot + cliPath: /usr/local/bin/copilot + ``` + Run `copilot login`. Verify with `copilot --version` (or your `cliPath`). ## Permission Denied @@ -70,9 +77,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`, `opencode.log`, or `cursor.log`): +1. Check the agent log for errors (the log file matches your agent: `claude.log`, `codex.log`, `opencode.log`, `cursor.log`, `gemini.log`, or `copilot.log`): ```bash - tail -100 .chief/prds/your-prd/claude.log # or codex.log / opencode.log / cursor.log + tail -100 .chief/prds/your-prd/copilot.log ``` 2. Manually mark story complete if appropriate by editing `prd.md`: @@ -92,7 +99,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 / cursor.log + tail -f .chief/prds/your-prd/copilot.log ``` 2. Simplify the current story's acceptance criteria @@ -121,7 +128,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`, `opencode.log`, or `cursor.log`) + - Stuck in a loop? Check the matching agent log (for example, `claude.log` or `copilot.log`) - Unclear acceptance criteria? Clarify them ## "No PRD Found" @@ -262,4 +269,4 @@ If none of these solutions help: 3. Open a new issue with: - Chief version (`chief --version`) - Your `prd.md` (sanitized) - - Relevant agent log excerpts (e.g. `claude.log`, `codex.log`, `opencode.log`, or `cursor.log`) + - Relevant agent log excerpts (for example, `claude.log` or `copilot.log`) diff --git a/internal/agent/copilot.go b/internal/agent/copilot.go new file mode 100644 index 00000000..98164fe2 --- /dev/null +++ b/internal/agent/copilot.go @@ -0,0 +1,88 @@ +package agent + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + + "github.com/minicodemonkey/chief/internal/loop" +) + +// CopilotProvider implements loop.Provider for GitHub Copilot CLI. +type CopilotProvider struct { + cliPath string +} + +// NewCopilotProvider returns a Provider for GitHub Copilot CLI. +// If cliPath is empty, "copilot" is used. +func NewCopilotProvider(cliPath string) *CopilotProvider { + if cliPath == "" { + cliPath = "copilot" + } + return &CopilotProvider{cliPath: cliPath} +} + +// Name implements loop.Provider. +func (p *CopilotProvider) Name() string { return "Copilot" } + +// CLIPath implements loop.Provider. +func (p *CopilotProvider) CLIPath() string { return p.cliPath } + +// LoopCommand implements loop.Provider. +func (p *CopilotProvider) LoopCommand(ctx context.Context, prompt, workDir string) *exec.Cmd { + cmd := exec.CommandContext(ctx, p.cliPath, + "-p", prompt, + "--output-format", "json", + "--allow-all", + "--no-ask-user", + "--no-color", + "--no-auto-update", + "--no-remote", + "--no-remote-export", + ) + cmd.Dir = workDir + return cmd +} + +// InteractiveCommand implements loop.Provider. +func (p *CopilotProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { + cmd := exec.Command(p.cliPath, "-i", prompt) + cmd.Dir = workDir + return cmd +} + +// ParseLine implements loop.Provider. +func (p *CopilotProvider) ParseLine(line string) *loop.Event { + return loop.ParseLineCopilot(line) +} + +// LogFileName implements loop.Provider. +func (p *CopilotProvider) LogFileName() string { return "copilot.log" } + +// CleanOutput extracts complete assistant messages from Copilot's JSONL output. +func (p *CopilotProvider) CleanOutput(output string) string { + output = strings.TrimSpace(output) + if output == "" { + return output + } + + var messages []string + for _, line := range strings.Split(output, "\n") { + var event struct { + Type string `json:"type"` + Data struct { + Content string `json:"content"` + } `json:"data"` + } + if json.Unmarshal([]byte(line), &event) == nil && + event.Type == "assistant.message" && + event.Data.Content != "" { + messages = append(messages, event.Data.Content) + } + } + if len(messages) > 0 { + return strings.Join(messages, "\n") + } + return output +} diff --git a/internal/agent/copilot_test.go b/internal/agent/copilot_test.go new file mode 100644 index 00000000..5457e17f --- /dev/null +++ b/internal/agent/copilot_test.go @@ -0,0 +1,82 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/minicodemonkey/chief/internal/loop" +) + +func TestCopilotProvider(t *testing.T) { + p := NewCopilotProvider("") + if p.Name() != "Copilot" { + t.Errorf("Name() = %q, want Copilot", p.Name()) + } + if p.CLIPath() != "copilot" { + t.Errorf("CLIPath() = %q, want copilot", p.CLIPath()) + } + if p.LogFileName() != "copilot.log" { + t.Errorf("LogFileName() = %q, want copilot.log", p.LogFileName()) + } + + custom := NewCopilotProvider("/opt/copilot") + if custom.CLIPath() != "/opt/copilot" { + t.Errorf("custom CLIPath() = %q, want /opt/copilot", custom.CLIPath()) + } +} + +func TestCopilotProvider_LoopCommand(t *testing.T) { + p := NewCopilotProvider("/bin/copilot") + cmd := p.LoopCommand(context.Background(), "build it", "/work") + + wantArgs := []string{ + "/bin/copilot", "-p", "build it", + "--output-format", "json", + "--allow-all", + "--no-ask-user", + "--no-color", + "--no-auto-update", + "--no-remote", + "--no-remote-export", + } + if strings.Join(cmd.Args, "\x00") != strings.Join(wantArgs, "\x00") { + t.Errorf("LoopCommand Args = %v, want %v", cmd.Args, wantArgs) + } + if cmd.Dir != "/work" { + t.Errorf("LoopCommand Dir = %q, want /work", cmd.Dir) + } +} + +func TestCopilotProvider_InteractiveCommand(t *testing.T) { + p := NewCopilotProvider("/bin/copilot") + cmd := p.InteractiveCommand("/work", "review this") + wantArgs := []string{"/bin/copilot", "-i", "review this"} + if strings.Join(cmd.Args, "\x00") != strings.Join(wantArgs, "\x00") { + t.Errorf("InteractiveCommand Args = %v, want %v", cmd.Args, wantArgs) + } + if cmd.Dir != "/work" { + t.Errorf("InteractiveCommand Dir = %q, want /work", cmd.Dir) + } +} + +func TestCopilotProvider_ParseLine(t *testing.T) { + p := NewCopilotProvider("") + event := p.ParseLine(`{"type":"assistant.message","data":{"content":"done "}}`) + if event == nil || event.Type != loop.EventStoryDone { + t.Fatalf("ParseLine() = %#v, want EventStoryDone", event) + } +} + +func TestCopilotProvider_CleanOutput(t *testing.T) { + p := NewCopilotProvider("") + output := `{"type":"assistant.message","data":{"content":"first"}} +{"type":"tool.execution_start","data":{"toolName":"bash"}} +{"type":"assistant.message","data":{"content":"second"}}` + if got := p.CleanOutput(output); got != "first\nsecond" { + t.Errorf("CleanOutput() = %q, want %q", got, "first\nsecond") + } + if got := p.CleanOutput("plain text"); got != "plain text" { + t.Errorf("CleanOutput(plain) = %q, want plain text", got) + } +} diff --git a/internal/agent/resolve.go b/internal/agent/resolve.go index 5aaa0995..c919299f 100644 --- a/internal/agent/resolve.go +++ b/internal/agent/resolve.go @@ -43,8 +43,10 @@ func Resolve(flagAgent, flagPath string, cfg *config.Config) (loop.Provider, err return NewCursorProvider(cliPath), nil case "gemini": return NewGeminiProvider(cliPath), nil + case "copilot", "copilot-cli": + return NewCopilotProvider(cliPath), nil default: - return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", \"opencode\", \"cursor\", or \"gemini\"", providerName) + return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", \"opencode\", \"cursor\", \"gemini\", or \"copilot\"", providerName) } } diff --git a/internal/agent/resolve_test.go b/internal/agent/resolve_test.go index 89aa9190..c9f8f59f 100644 --- a/internal/agent/resolve_test.go +++ b/internal/agent/resolve_test.go @@ -178,6 +178,23 @@ func TestResolve_gemini(t *testing.T) { } } +func TestResolve_copilot(t *testing.T) { + for _, name := range []string{"copilot", "copilot-cli"} { + got := mustResolve(t, name, "", nil) + if got.Name() != "Copilot" { + t.Errorf("Resolve(%s) name = %q, want Copilot", name, got.Name()) + } + if got.CLIPath() != "copilot" { + t.Errorf("Resolve(%s) CLIPath = %q, want copilot", name, got.CLIPath()) + } + } + + got := mustResolve(t, "copilot", "/usr/local/bin/copilot", nil) + if got.CLIPath() != "/usr/local/bin/copilot" { + t.Errorf("Resolve(copilot, custom path) CLIPath = %q, want /usr/local/bin/copilot", got.CLIPath()) + } +} + func TestResolve_unknownProvider(t *testing.T) { _, err := Resolve("typo", "", nil) if err == nil { @@ -189,6 +206,9 @@ func TestResolve_unknownProvider(t *testing.T) { if !strings.Contains(err.Error(), "gemini") { t.Errorf("error should mention gemini as a valid option: %v", err) } + if !strings.Contains(err.Error(), "copilot") { + t.Errorf("error should mention copilot as a valid option: %v", err) + } } func TestCheckInstalled_notFound(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index 3b347643..2b391844 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, OpenCode, or Cursor). +// AgentConfig holds agent CLI settings. type AgentConfig struct { - Provider string `yaml:"provider"` // "claude" (default) | "codex" | "opencode" | "cursor" | "gemini" + Provider string `yaml:"provider"` // "claude" (default) | "codex" | "opencode" | "cursor" | "gemini" | "copilot" CLIPath string `yaml:"cliPath"` // optional custom path to CLI binary } diff --git a/internal/loop/copilot_parser.go b/internal/loop/copilot_parser.go new file mode 100644 index 00000000..ba0a036e --- /dev/null +++ b/internal/loop/copilot_parser.go @@ -0,0 +1,77 @@ +package loop + +import ( + "encoding/json" + "strings" +) + +type copilotEvent struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +type copilotAssistantMessage struct { + Content string `json:"content"` +} + +type copilotToolStart struct { + ToolName string `json:"toolName"` + Arguments map[string]interface{} `json:"arguments"` +} + +type copilotToolComplete struct { + ToolName string `json:"toolName"` + Success bool `json:"success"` + Result struct { + Content string `json:"content"` + DetailedContent string `json:"detailedContent"` + } `json:"result"` +} + +// ParseLineCopilot parses a single line of GitHub Copilot CLI JSONL output. +func ParseLineCopilot(line string) *Event { + line = strings.TrimSpace(line) + if line == "" { + return nil + } + + var event copilotEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + return nil + } + + switch event.Type { + case "assistant.turn_start": + return &Event{Type: EventIterationStart} + case "assistant.message": + var message copilotAssistantMessage + if json.Unmarshal(event.Data, &message) != nil || message.Content == "" { + return nil + } + if strings.Contains(message.Content, "") { + return &Event{Type: EventComplete, Text: message.Content} + } + if strings.Contains(message.Content, "") { + return &Event{Type: EventStoryDone, Text: message.Content} + } + return &Event{Type: EventAssistantText, Text: message.Content} + case "tool.execution_start": + var tool copilotToolStart + if json.Unmarshal(event.Data, &tool) != nil { + return nil + } + return &Event{Type: EventToolStart, Tool: tool.ToolName, ToolInput: tool.Arguments} + case "tool.execution_complete": + var tool copilotToolComplete + if json.Unmarshal(event.Data, &tool) != nil { + return nil + } + result := tool.Result.Content + if result == "" { + result = tool.Result.DetailedContent + } + return &Event{Type: EventToolResult, Tool: tool.ToolName, Text: result} + default: + return nil + } +} diff --git a/internal/loop/copilot_parser_test.go b/internal/loop/copilot_parser_test.go new file mode 100644 index 00000000..582d1db2 --- /dev/null +++ b/internal/loop/copilot_parser_test.go @@ -0,0 +1,78 @@ +package loop + +import "testing" + +func TestParseLineCopilot(t *testing.T) { + tests := []struct { + name string + line string + want EventType + text string + tool string + }{ + { + name: "turn start", + line: `{"type":"assistant.turn_start","data":{"turnId":"0"}}`, + want: EventIterationStart, + }, + { + name: "assistant text", + line: `{"type":"assistant.message","data":{"content":"working on it"}}`, + want: EventAssistantText, + text: "working on it", + }, + { + name: "story done", + line: `{"type":"assistant.message","data":{"content":"done "}}`, + want: EventStoryDone, + text: "done ", + }, + { + name: "complete", + line: `{"type":"assistant.message","data":{"content":"all done "}}`, + want: EventComplete, + text: "all done ", + }, + { + name: "tool start", + line: `{"type":"tool.execution_start","data":{"toolName":"bash","arguments":{"command":"go test ./..."}}}`, + want: EventToolStart, + tool: "bash", + }, + { + name: "tool complete", + line: `{"type":"tool.execution_complete","data":{"toolCallId":"toolu_123","success":true,"result":{"content":"ok"}}}`, + want: EventToolResult, + text: "ok", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := ParseLineCopilot(tt.line) + if event == nil { + t.Fatal("ParseLineCopilot() returned nil") + } + if event.Type != tt.want || event.Text != tt.text || event.Tool != tt.tool { + t.Errorf("ParseLineCopilot() = %#v, want type=%v text=%q tool=%q", event, tt.want, tt.text, tt.tool) + } + if tt.name == "tool start" && event.ToolInput["command"] != "go test ./..." { + t.Errorf("ToolInput = %v, want command", event.ToolInput) + } + }) + } +} + +func TestParseLineCopilot_IgnoresIrrelevantInput(t *testing.T) { + for _, line := range []string{ + "", + "not json", + `{"type":"assistant.message_delta","data":{"deltaContent":"partial"}}`, + `{"type":"assistant.message","data":{"content":"","toolRequests":[]}}`, + `{"type":"result","exitCode":0}`, + } { + if event := ParseLineCopilot(line); event != nil { + t.Errorf("ParseLineCopilot(%q) = %#v, want nil", line, event) + } + } +}