From 0f6bb5831eba1a205044e31356526459552ecf67 Mon Sep 17 00:00:00 2001 From: Alexandre NICOLAIE Date: Thu, 26 Mar 2026 18:50:03 +0100 Subject: [PATCH] feat: add Gemini CLI integration Co-authored-by: Claude Sonnet 4.6 Signed-off-by: Alexandre NICOLAIE --- README.md | 6 +- cmd/chief/main.go | 4 +- docs/reference/configuration.md | 2 +- internal/agent/gemini.go | 91 ++++++++++++++++ internal/agent/gemini_test.go | 159 ++++++++++++++++++++++++++++ internal/agent/resolve.go | 4 +- internal/agent/resolve_test.go | 31 ++++++ internal/config/config.go | 2 +- internal/loop/gemini_parser.go | 108 +++++++++++++++++++ internal/loop/gemini_parser_test.go | 155 +++++++++++++++++++++++++++ 10 files changed, 555 insertions(+), 7 deletions(-) create mode 100644 internal/agent/gemini.go create mode 100644 internal/agent/gemini_test.go create mode 100644 internal/loop/gemini_parser.go create mode 100644 internal/loop/gemini_parser_test.go diff --git a/README.md b/README.md index 0d6b8d5..7472b79 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ 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)**, or **[OpenCode CLI](https://opencode.ai)** 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)**, or **[Gemini CLI](https://github.com/google-gemini/gemini-cli)** installed and authenticated -Use Claude by default, or configure Codex or OpenCode in `.chief/config.yaml`: +Use Claude by default, or configure another provider in `.chief/config.yaml`: ```yaml agent: @@ -56,6 +56,8 @@ agent: Or run with `chief --agent opencode` or set `CHIEF_AGENT=opencode`. +Supported values for `provider` are: `claude`, `codex`, `opencode`, `cursor`, and `gemini`. + ## License MIT diff --git a/cmd/chief/main.go b/cmd/chief/main.go index 016c188..83abbb2 100644 --- a/cmd/chief/main.go +++ b/cmd/chief/main.go @@ -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, or cursor)\n") + fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude, codex, opencode, cursor or gemini)\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, or cursor + --agent Agent CLI to use: claude (default), codex, opencode, cursor, or gemini --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 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5417b85..1cb5f7b 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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**, or **Cursor CLI** as the agent. Choose via: +Chief can use **Claude Code** (default), **Codex CLI**, **OpenCode CLI**, **Cursor CLI**, or **Gemini 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/internal/agent/gemini.go b/internal/agent/gemini.go new file mode 100644 index 0000000..a16b658 --- /dev/null +++ b/internal/agent/gemini.go @@ -0,0 +1,91 @@ +package agent + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + + "github.com/minicodemonkey/chief/internal/loop" +) + +// GeminiProvider implements loop.Provider for the Gemini CLI. +type GeminiProvider struct { + cliPath string +} + +// NewGeminiProvider returns a Provider for the Gemini CLI. +// If cliPath is empty, "gemini" is used. +func NewGeminiProvider(cliPath string) *GeminiProvider { + if cliPath == "" { + cliPath = "gemini" + } + return &GeminiProvider{cliPath: cliPath} +} + +// Name implements loop.Provider. +func (p *GeminiProvider) Name() string { return "Gemini" } + +// CLIPath implements loop.Provider. +func (p *GeminiProvider) CLIPath() string { return p.cliPath } + +// LoopCommand implements loop.Provider. +// Runs Gemini in non-interactive (headless) mode with streaming JSON output +// and YOLO approval mode so all tool calls are auto-approved. +func (p *GeminiProvider) LoopCommand(ctx context.Context, prompt, workDir string) *exec.Cmd { + cmd := exec.CommandContext(ctx, p.cliPath, + "-p", prompt, + "--output-format", "stream-json", + "--yolo", + ) + cmd.Dir = workDir + return cmd +} + +// InteractiveCommand implements loop.Provider. +func (p *GeminiProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { + cmd := exec.Command(p.cliPath, prompt) + cmd.Dir = workDir + return cmd +} + +// ParseLine implements loop.Provider. +func (p *GeminiProvider) ParseLine(line string) *loop.Event { + return loop.ParseLineGemini(line) +} + +// LogFileName implements loop.Provider. +func (p *GeminiProvider) LogFileName() string { return "gemini.log" } + +// geminiAssistantMessage is used by CleanOutput to extract assistant text deltas. +type geminiAssistantMessage struct { + Type string `json:"type"` + Role string `json:"role"` + Content string `json:"content"` +} + +// CleanOutput concatenates all assistant message delta chunks from Gemini's +// stream-json NDJSON output and returns the full assistant response. +// Falls back to the raw output if no assistant messages are found. +func (p *GeminiProvider) CleanOutput(output string) string { + output = strings.TrimSpace(output) + if output == "" { + return output + } + + var sb strings.Builder + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var msg geminiAssistantMessage + if json.Unmarshal([]byte(line), &msg) == nil && msg.Type == "message" && msg.Role == "assistant" && msg.Content != "" { + sb.WriteString(msg.Content) + } + } + if sb.Len() > 0 { + return sb.String() + } + return output +} diff --git a/internal/agent/gemini_test.go b/internal/agent/gemini_test.go new file mode 100644 index 0000000..32750f3 --- /dev/null +++ b/internal/agent/gemini_test.go @@ -0,0 +1,159 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/minicodemonkey/chief/internal/loop" +) + +func TestGeminiProvider_Name(t *testing.T) { + p := NewGeminiProvider("") + if p.Name() != "Gemini" { + t.Errorf("Name() = %q, want Gemini", p.Name()) + } +} + +func TestGeminiProvider_CLIPath(t *testing.T) { + p := NewGeminiProvider("") + if p.CLIPath() != "gemini" { + t.Errorf("CLIPath() empty arg = %q, want gemini", p.CLIPath()) + } + p2 := NewGeminiProvider("/usr/local/bin/gemini") + if p2.CLIPath() != "/usr/local/bin/gemini" { + t.Errorf("CLIPath() custom = %q, want /usr/local/bin/gemini", p2.CLIPath()) + } +} + +func TestGeminiProvider_LogFileName(t *testing.T) { + p := NewGeminiProvider("") + if p.LogFileName() != "gemini.log" { + t.Errorf("LogFileName() = %q, want gemini.log", p.LogFileName()) + } +} + +func TestGeminiProvider_LoopCommand(t *testing.T) { + ctx := context.Background() + p := NewGeminiProvider("/bin/gemini") + cmd := p.LoopCommand(ctx, "hello world", "/work/dir") + + if cmd.Path != "/bin/gemini" { + t.Errorf("LoopCommand Path = %q, want /bin/gemini", cmd.Path) + } + wantArgs := []string{"/bin/gemini", "-p", "hello world", "--output-format", "stream-json", "--yolo"} + 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) + } +} + +func TestGeminiProvider_InteractiveCommand(t *testing.T) { + p := NewGeminiProvider("/bin/gemini") + 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/gemini" || cmd.Args[1] != "my prompt" { + t.Errorf("InteractiveCommand Args = %v, want [/bin/gemini my prompt]", cmd.Args) + } +} + +func TestGeminiProvider_ParseLine(t *testing.T) { + p := NewGeminiProvider("") + + // init event -> EventIterationStart + e := p.ParseLine(`{"type":"init","timestamp":"2025-01-01T00:00:00.000Z","session_id":"abc","model":"gemini-2.5-pro"}`) + if e == nil { + t.Fatal("ParseLine(init) returned nil") + } + if e.Type != loop.EventIterationStart { + t.Errorf("ParseLine(init) Type = %v, want EventIterationStart", e.Type) + } + + // assistant message -> EventAssistantText + e = p.ParseLine(`{"type":"message","timestamp":"2025-01-01T00:00:00.000Z","role":"assistant","content":"Hello!","delta":true}`) + if e == nil { + t.Fatal("ParseLine(assistant message) returned nil") + } + if e.Type != loop.EventAssistantText { + t.Errorf("ParseLine(assistant message) Type = %v, want EventAssistantText", e.Type) + } + if e.Text != "Hello!" { + t.Errorf("ParseLine(assistant message) Text = %q, want Hello!", e.Text) + } + + // chief-done tag -> EventStoryDone + e = p.ParseLine(`{"type":"message","timestamp":"2025-01-01T00:00:00.000Z","role":"assistant","content":"Done ","delta":true}`) + if e == nil { + t.Fatal("ParseLine(chief-done) returned nil") + } + if e.Type != loop.EventStoryDone { + t.Errorf("ParseLine(chief-done) Type = %v, want EventStoryDone", e.Type) + } +} + +func TestGeminiProvider_CleanOutput_singleChunk(t *testing.T) { + p := NewGeminiProvider("") + input := `{"type":"init","session_id":"s1","model":"gemini-2.5-pro"} +{"type":"message","role":"assistant","content":"Hello from Gemini!","delta":true} +{"type":"result","status":"success","stats":{}}` + got := p.CleanOutput(input) + if got != "Hello from Gemini!" { + t.Errorf("CleanOutput(single chunk) = %q, want %q", got, "Hello from Gemini!") + } +} + +func TestGeminiProvider_CleanOutput_multipleChunks(t *testing.T) { + p := NewGeminiProvider("") + input := `{"type":"init","session_id":"s1","model":"gemini-2.5-pro"} +{"type":"message","role":"assistant","content":"Hello ","delta":true} +{"type":"message","role":"assistant","content":"from ","delta":true} +{"type":"message","role":"assistant","content":"Gemini!","delta":true} +{"type":"result","status":"success","stats":{}}` + got := p.CleanOutput(input) + want := "Hello from Gemini!" + if got != want { + t.Errorf("CleanOutput(multiple chunks) = %q, want %q", got, want) + } +} + +func TestGeminiProvider_CleanOutput_noAssistantMessages(t *testing.T) { + p := NewGeminiProvider("") + // When there are no assistant messages, fall back to the raw output. + input := `{"type":"result","status":"success","stats":{}}` + got := p.CleanOutput(input) + if got != input { + t.Errorf("CleanOutput(no assistant) = %q, want raw output %q", got, input) + } +} + +func TestGeminiProvider_CleanOutput_empty(t *testing.T) { + p := NewGeminiProvider("") + if p.CleanOutput("") != "" { + t.Errorf("CleanOutput('') should return empty string") + } + if p.CleanOutput(" ") != "" { + t.Errorf("CleanOutput(' ') should return empty string") + } +} + +func TestGeminiProvider_CleanOutput_skipsUserMessages(t *testing.T) { + p := NewGeminiProvider("") + input := `{"type":"message","role":"user","content":"do something","delta":false} +{"type":"message","role":"assistant","content":"Sure!","delta":true}` + got := p.CleanOutput(input) + if got != "Sure!" { + t.Errorf("CleanOutput(skips user) = %q, want %q", got, "Sure!") + } + if strings.Contains(got, "do something") { + t.Errorf("CleanOutput should not include user messages in output") + } +} diff --git a/internal/agent/resolve.go b/internal/agent/resolve.go index 2458740..5aaa099 100644 --- a/internal/agent/resolve.go +++ b/internal/agent/resolve.go @@ -41,8 +41,10 @@ func Resolve(flagAgent, flagPath string, cfg *config.Config) (loop.Provider, err return NewOpenCodeProvider(cliPath), nil case "cursor": return NewCursorProvider(cliPath), nil + case "gemini": + return NewGeminiProvider(cliPath), nil default: - return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", \"opencode\", or \"cursor\"", providerName) + return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", \"opencode\", \"cursor\", or \"gemini\"", providerName) } } diff --git a/internal/agent/resolve_test.go b/internal/agent/resolve_test.go index 32796f1..89aa919 100644 --- a/internal/agent/resolve_test.go +++ b/internal/agent/resolve_test.go @@ -150,6 +150,34 @@ func TestResolve_cursor(t *testing.T) { } } +func TestResolve_gemini(t *testing.T) { + got := mustResolve(t, "gemini", "", nil) + if got.Name() != "Gemini" { + t.Errorf("Resolve(gemini) name = %q, want Gemini", got.Name()) + } + if got.CLIPath() != "gemini" { + t.Errorf("Resolve(gemini) CLIPath = %q, want gemini", got.CLIPath()) + } + + // Custom path + got = mustResolve(t, "gemini", "/usr/local/bin/gemini", nil) + if got.CLIPath() != "/usr/local/bin/gemini" { + t.Errorf("Resolve(gemini, /usr/local/bin/gemini) CLIPath = %q, want /usr/local/bin/gemini", got.CLIPath()) + } + + // From config + cfg := &config.Config{} + cfg.Agent.Provider = "gemini" + cfg.Agent.CLIPath = "/opt/gemini" + got = mustResolve(t, "", "", cfg) + if got.Name() != "Gemini" { + t.Errorf("Resolve(_, _, config gemini) name = %q, want Gemini", got.Name()) + } + if got.CLIPath() != "/opt/gemini" { + t.Errorf("Resolve(_, _, config gemini) CLIPath = %q, want /opt/gemini", got.CLIPath()) + } +} + func TestResolve_unknownProvider(t *testing.T) { _, err := Resolve("typo", "", nil) if err == nil { @@ -158,6 +186,9 @@ func TestResolve_unknownProvider(t *testing.T) { if !strings.Contains(err.Error(), "typo") { t.Errorf("error should mention the bad provider name: %v", err) } + if !strings.Contains(err.Error(), "gemini") { + t.Errorf("error should mention gemini 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 4523b1b..3b34764 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,7 +18,7 @@ type Config struct { // AgentConfig holds agent CLI settings (Claude, Codex, OpenCode, or Cursor). type AgentConfig struct { - Provider string `yaml:"provider"` // "claude" (default) | "codex" | "opencode" | "cursor" + Provider string `yaml:"provider"` // "claude" (default) | "codex" | "opencode" | "cursor" | "gemini" CLIPath string `yaml:"cliPath"` // optional custom path to CLI binary } diff --git a/internal/loop/gemini_parser.go b/internal/loop/gemini_parser.go new file mode 100644 index 0000000..0883588 --- /dev/null +++ b/internal/loop/gemini_parser.go @@ -0,0 +1,108 @@ +package loop + +import ( + "encoding/json" + "strings" +) + +// geminiStreamEvent is the top-level structure for a Gemini stream-json line. +type geminiStreamEvent struct { + Type string `json:"type"` +} + +// geminiInitEvent represents the "init" event emitted at session start. +type geminiInitEvent struct { + Type string `json:"type"` + SessionID string `json:"session_id"` + Model string `json:"model"` +} + +// geminiMessageEvent represents a "message" event (user or assistant delta). +type geminiMessageEvent struct { + Type string `json:"type"` + Role string `json:"role"` + Content string `json:"content"` + Delta bool `json:"delta"` +} + +// geminiToolUseEvent represents a "tool_use" event (tool call request). +type geminiToolUseEvent struct { + Type string `json:"type"` + ToolName string `json:"tool_name"` + ToolID string `json:"tool_id"` + Parameters map[string]interface{} `json:"parameters"` +} + +// geminiToolResultEvent represents a "tool_result" event. +type geminiToolResultEvent struct { + Type string `json:"type"` + ToolID string `json:"tool_id"` + Status string `json:"status"` + Output string `json:"output,omitempty"` +} + +// geminiErrorEvent represents an "error" event. +type geminiErrorEvent struct { + Type string `json:"type"` + Severity string `json:"severity"` + Message string `json:"message"` +} + +// ParseLineGemini parses a single line of Gemini's stream-json output and +// returns an Event. Returns nil for lines that are not relevant to Chief. +func ParseLineGemini(line string) *Event { + line = strings.TrimSpace(line) + if line == "" { + return nil + } + + // Peek at the type field first. + var base geminiStreamEvent + if err := json.Unmarshal([]byte(line), &base); err != nil { + return nil + } + + switch base.Type { + case "init": + // Session start maps to EventIterationStart. + return &Event{Type: EventIterationStart} + + case "message": + var msg geminiMessageEvent + if err := json.Unmarshal([]byte(line), &msg); err != nil { + return nil + } + if msg.Role != "assistant" || msg.Content == "" { + return nil + } + if strings.Contains(msg.Content, "") { + return &Event{Type: EventStoryDone, Text: msg.Content} + } + return &Event{Type: EventAssistantText, Text: msg.Content} + + case "tool_use": + var tu geminiToolUseEvent + if err := json.Unmarshal([]byte(line), &tu); err != nil { + return nil + } + return &Event{ + Type: EventToolStart, + Tool: tu.ToolName, + ToolInput: tu.Parameters, + } + + case "tool_result": + var tr geminiToolResultEvent + if err := json.Unmarshal([]byte(line), &tr); err != nil { + return nil + } + return &Event{Type: EventToolResult, Text: tr.Output} + + case "result", "error": + // Terminal / metadata events — not actionable inside the loop. + return nil + + default: + return nil + } +} diff --git a/internal/loop/gemini_parser_test.go b/internal/loop/gemini_parser_test.go new file mode 100644 index 0000000..b2118ce --- /dev/null +++ b/internal/loop/gemini_parser_test.go @@ -0,0 +1,155 @@ +package loop + +import ( + "testing" +) + +func TestParseLineGemini_init(t *testing.T) { + line := `{"type":"init","timestamp":"2025-01-01T00:00:00.000Z","session_id":"abc123","model":"gemini-2.5-pro"}` + e := ParseLineGemini(line) + if e == nil { + t.Fatal("ParseLineGemini(init) returned nil") + } + if e.Type != EventIterationStart { + t.Errorf("ParseLineGemini(init) Type = %v, want EventIterationStart", e.Type) + } +} + +func TestParseLineGemini_assistantMessage(t *testing.T) { + line := `{"type":"message","timestamp":"2025-01-01T00:00:00.000Z","role":"assistant","content":"Hello, world!","delta":true}` + e := ParseLineGemini(line) + if e == nil { + t.Fatal("ParseLineGemini(assistant message) returned nil") + } + if e.Type != EventAssistantText { + t.Errorf("ParseLineGemini(assistant message) Type = %v, want EventAssistantText", e.Type) + } + if e.Text != "Hello, world!" { + t.Errorf("ParseLineGemini(assistant message) Text = %q, want %q", e.Text, "Hello, world!") + } +} + +func TestParseLineGemini_userMessage(t *testing.T) { + // User messages should be ignored. + line := `{"type":"message","timestamp":"2025-01-01T00:00:00.000Z","role":"user","content":"do something","delta":false}` + e := ParseLineGemini(line) + if e != nil { + t.Errorf("ParseLineGemini(user message) expected nil, got %v", e) + } +} + +func TestParseLineGemini_emptyContent(t *testing.T) { + // Assistant message with empty content should be ignored. + line := `{"type":"message","timestamp":"2025-01-01T00:00:00.000Z","role":"assistant","content":"","delta":true}` + e := ParseLineGemini(line) + if e != nil { + t.Errorf("ParseLineGemini(empty content) expected nil, got %v", e) + } +} + +func TestParseLineGemini_storyDone(t *testing.T) { + line := `{"type":"message","timestamp":"2025-01-01T00:00:00.000Z","role":"assistant","content":"All done! ","delta":true}` + e := ParseLineGemini(line) + if e == nil { + t.Fatal("ParseLineGemini(chief-done) returned nil") + } + if e.Type != EventStoryDone { + t.Errorf("ParseLineGemini(chief-done) Type = %v, want EventStoryDone", e.Type) + } + if e.Text != "All done! " { + t.Errorf("ParseLineGemini(chief-done) Text = %q, want %q", e.Text, "All done! ") + } +} + +func TestParseLineGemini_toolUse(t *testing.T) { + line := `{"type":"tool_use","timestamp":"2025-01-01T00:00:00.000Z","tool_name":"read_file","tool_id":"call_1","parameters":{"path":"/foo/bar.go"}}` + e := ParseLineGemini(line) + if e == nil { + t.Fatal("ParseLineGemini(tool_use) returned nil") + } + if e.Type != EventToolStart { + t.Errorf("ParseLineGemini(tool_use) Type = %v, want EventToolStart", e.Type) + } + if e.Tool != "read_file" { + t.Errorf("ParseLineGemini(tool_use) Tool = %q, want read_file", e.Tool) + } + if e.ToolInput == nil { + t.Fatal("ParseLineGemini(tool_use) ToolInput is nil") + } + if v, ok := e.ToolInput["path"]; !ok || v != "/foo/bar.go" { + t.Errorf("ParseLineGemini(tool_use) ToolInput[path] = %v, want /foo/bar.go", e.ToolInput["path"]) + } +} + +func TestParseLineGemini_toolResult(t *testing.T) { + line := `{"type":"tool_result","timestamp":"2025-01-01T00:00:00.000Z","tool_id":"call_1","status":"success","output":"file contents here"}` + e := ParseLineGemini(line) + if e == nil { + t.Fatal("ParseLineGemini(tool_result) returned nil") + } + if e.Type != EventToolResult { + t.Errorf("ParseLineGemini(tool_result) Type = %v, want EventToolResult", e.Type) + } + if e.Text != "file contents here" { + t.Errorf("ParseLineGemini(tool_result) Text = %q, want %q", e.Text, "file contents here") + } +} + +func TestParseLineGemini_result(t *testing.T) { + // Final result event should return nil (not actionable in the loop). + line := `{"type":"result","timestamp":"2025-01-01T00:00:00.000Z","status":"success","stats":{}}` + e := ParseLineGemini(line) + if e != nil { + t.Errorf("ParseLineGemini(result) expected nil, got %v", e) + } +} + +func TestParseLineGemini_error(t *testing.T) { + // Error event should return nil (handled externally via stderr/exit code). + line := `{"type":"error","timestamp":"2025-01-01T00:00:00.000Z","severity":"error","message":"something went wrong"}` + e := ParseLineGemini(line) + if e != nil { + t.Errorf("ParseLineGemini(error) expected nil, got %v", e) + } +} + +func TestParseLineGemini_unknown(t *testing.T) { + line := `{"type":"unknown_event","data":"foo"}` + e := ParseLineGemini(line) + if e != nil { + t.Errorf("ParseLineGemini(unknown) expected nil, got %v", e) + } +} + +func TestParseLineGemini_emptyLine(t *testing.T) { + e := ParseLineGemini("") + if e != nil { + t.Errorf("ParseLineGemini('') expected nil, got %v", e) + } + e = ParseLineGemini(" ") + if e != nil { + t.Errorf("ParseLineGemini(' ') expected nil, got %v", e) + } +} + +func TestParseLineGemini_invalidJSON(t *testing.T) { + e := ParseLineGemini("not json at all") + if e != nil { + t.Errorf("ParseLineGemini(invalid json) expected nil, got %v", e) + } +} + +func TestParseLineGemini_toolUseNoParams(t *testing.T) { + // Tool use event with no parameters should still parse. + line := `{"type":"tool_use","timestamp":"2025-01-01T00:00:00.000Z","tool_name":"list_files","tool_id":"call_2","parameters":{}}` + e := ParseLineGemini(line) + if e == nil { + t.Fatal("ParseLineGemini(tool_use no params) returned nil") + } + if e.Type != EventToolStart { + t.Errorf("ParseLineGemini(tool_use no params) Type = %v, want EventToolStart", e.Type) + } + if e.Tool != "list_files" { + t.Errorf("ParseLineGemini(tool_use no params) Tool = %q, want list_files", e.Tool) + } +}