初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+604
View File
@@ -0,0 +1,604 @@
package gemini
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterAgent("gemini", New)
}
// Agent drives the Gemini CLI in headless mode using -p --output-format stream-json.
//
// Modes (maps to Gemini CLI approval flags):
// - "default": standard approval mode (prompt for each tool use)
// - "auto_edit": auto-approve edit tools, ask for others
// - "yolo": auto-approve all tools (-y / --approval-mode yolo)
// - "plan": read-only plan mode (--approval-mode plan)
type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "gemini"
timeout time.Duration
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
mu sync.RWMutex
}
func New(opts map[string]any) (core.Agent, error) {
workDir, _ := opts["work_dir"].(string)
if workDir == "" {
workDir = "."
}
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "gemini"
}
var timeoutMins int64
switch v := opts["timeout_mins"].(type) {
case int64:
timeoutMins = v
case int:
timeoutMins = int64(v)
case float64:
timeoutMins = int64(v)
default:
if v != nil {
slog.Debug("gemini: timeout_mins has unexpected type", "type", fmt.Sprintf("%T", v))
}
}
var timeout time.Duration
if timeoutMins > 0 {
timeout = time.Duration(timeoutMins) * time.Minute
}
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("gemini: %q CLI not found in PATH, install with: npm i -g @google/gemini-cli", cmd)
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
timeout: timeout,
activeIdx: -1,
}, nil
}
func normalizeMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "yolo", "auto", "force", "bypasspermissions":
return "yolo"
case "auto_edit", "autoedit", "edit", "acceptedits":
return "auto_edit"
case "plan":
return "plan"
default:
return "default"
}
}
func (a *Agent) Name() string { return "gemini" }
func (a *Agent) SetWorkDir(dir string) {
a.mu.Lock()
defer a.mu.Unlock()
a.workDir = dir
slog.Info("gemini: work_dir changed", "work_dir", dir)
}
func (a *Agent) GetWorkDir() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.workDir
}
func (a *Agent) SetModel(model string) {
a.mu.Lock()
defer a.mu.Unlock()
a.model = model
slog.Info("gemini: model changed", "model", model)
}
func (a *Agent) GetModel() string {
a.mu.Lock()
defer a.mu.Unlock()
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
}
func (a *Agent) configuredModels() []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
return core.GetProviderModels(a.providers, a.activeIdx)
}
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
if models := a.configuredModels(); len(models) > 0 {
return models
}
if models := a.fetchModelsFromAPI(ctx); len(models) > 0 {
return models
}
// Matches Gemini CLI's own "Select Model" list.
return []core.ModelOption{
{Name: "gemini-3.1-pro-preview", Desc: "Gemini 3.1 Pro Preview"},
{Name: "gemini-3-flash-preview", Desc: "Gemini 3 Flash Preview"},
{Name: "gemini-2.5-pro", Desc: "Gemini 2.5 Pro"},
{Name: "gemini-2.5-flash", Desc: "Gemini 2.5 Flash"},
{Name: "gemini-2.5-flash-lite", Desc: "Gemini 2.5 Flash Lite"},
}
}
func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {
apiKey := os.Getenv("GEMINI_API_KEY")
if apiKey == "" {
apiKey = os.Getenv("GOOGLE_API_KEY")
}
if apiKey == "" {
return nil
}
url := "https://generativelanguage.googleapis.com/v1beta/models?key=" + apiKey
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Debug("gemini: failed to fetch models", "error", err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
var result struct {
Models []struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
} `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil
}
var models []core.ModelOption
for _, m := range result.Models {
id := strings.TrimPrefix(m.Name, "models/")
if !strings.HasPrefix(id, "gemini-") {
continue
}
models = append(models, core.ModelOption{Name: id, Desc: m.DisplayName})
}
sort.Slice(models, func(i, j int) bool { return models[i].Name > models[j].Name })
return models
}
func (a *Agent) SetSessionEnv(env []string) {
a.mu.Lock()
defer a.mu.Unlock()
a.sessionEnv = env
}
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
a.mu.Lock()
model := a.model
mode := a.mode
cmd := a.cmd
workDir := a.workDir
timeout := a.timeout
extraEnv := a.providerEnvLocked()
extraEnv = append(extraEnv, a.sessionEnv...)
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
model = m
}
}
a.mu.Unlock()
return newGeminiSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout)
}
// ListSessions reads sessions from ~/.gemini/tmp/<project_hash>/chats/.
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
return listGeminiSessions(a.workDir)
}
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("gemini: cannot determine home dir: %w", err)
}
chatsDir := filepath.Join(homeDir, ".gemini", "tmp", geminiProjectSlug(a.workDir), "chats")
// Session files are named session-<timestamp>-<uuid_prefix>.json, not <uuid>.json.
// Scan the directory to find the file containing the matching sessionId.
entries, err := os.ReadDir(chatsDir)
if err != nil {
return fmt.Errorf("session file not found: %s", sessionID)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
fpath := filepath.Join(chatsDir, entry.Name())
data, err := os.ReadFile(fpath)
if err != nil {
continue
}
var sf struct {
SessionID string `json:"sessionId"`
}
if json.Unmarshal(data, &sf) == nil && sf.SessionID == sessionID {
return os.Remove(fpath)
}
}
return fmt.Errorf("session file not found: %s", sessionID)
}
func (a *Agent) Stop() error { return nil }
// ── ModeSwitcher ────────────────────────────────────────────────
func (a *Agent) SetMode(mode string) {
a.mu.Lock()
defer a.mu.Unlock()
a.mode = normalizeMode(mode)
slog.Info("gemini: mode changed", "mode", a.mode)
}
func (a *Agent) GetMode() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.mode
}
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
return []core.PermissionModeInfo{
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Prompt for approval on each tool use", DescZh: "每次工具调用都需要确认"},
{Key: "auto_edit", Name: "Auto Edit", NameZh: "自动编辑", Desc: "Auto-approve edit tools, ask for others", DescZh: "编辑工具自动通过,其他仍需确认"},
{Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
{Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only plan mode, no execution", DescZh: "只读规划模式,不做修改"},
}
}
// ── CommandProvider implementation ────────────────────────────
func (a *Agent) CommandDirs() []string {
absDir, err := filepath.Abs(a.workDir)
if err != nil {
absDir = a.workDir
}
dirs := []string{filepath.Join(absDir, ".gemini", "commands")}
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(home, ".gemini", "commands"))
}
return dirs
}
// ── SkillProvider implementation ──────────────────────────────
func (a *Agent) SkillDirs() []string {
absDir, err := filepath.Abs(a.workDir)
if err != nil {
absDir = a.workDir
}
dirs := []string{filepath.Join(absDir, ".gemini", "skills")}
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(home, ".gemini", "skills"))
}
return dirs
}
// ── ContextCompressor implementation ──────────────────────────
// Gemini CLI has no interactive compress/compact command.
// Return "" so engine reports "not supported" instead of sending
// a bogus "/compress" prompt to the model.
func (a *Agent) CompressCommand() string { return "" }
// ── MemoryFileProvider implementation ─────────────────────────
func (a *Agent) ProjectMemoryFile() string {
absDir, err := filepath.Abs(a.workDir)
if err != nil {
absDir = a.workDir
}
return filepath.Join(absDir, "GEMINI.md")
}
func (a *Agent) GlobalMemoryFile() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(homeDir, ".gemini", "GEMINI.md")
}
// ── ProviderSwitcher ────────────────────────────────────────────
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
a.mu.Lock()
defer a.mu.Unlock()
a.providers = providers
}
func (a *Agent) SetActiveProvider(name string) bool {
a.mu.Lock()
defer a.mu.Unlock()
if name == "" {
a.activeIdx = -1
slog.Info("gemini: provider cleared")
return true
}
for i, p := range a.providers {
if p.Name == name {
a.activeIdx = i
slog.Info("gemini: provider switched", "provider", name)
return true
}
}
return false
}
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
return &p
}
func (a *Agent) ListProviders() []core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
result := make([]core.ProviderConfig, len(a.providers))
copy(result, a.providers)
return result
}
func (a *Agent) providerEnvLocked() []string {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
var env []string
if p.APIKey != "" {
env = append(env, "GEMINI_API_KEY="+p.APIKey)
}
for k, v := range p.Env {
env = append(env, k+"="+v)
}
return env
}
// ── Session listing ─────────────────────────────────────────────
// geminiProjectSlug looks up the directory name Gemini CLI uses under ~/.gemini/tmp/
// for a given project path. It reads ~/.gemini/projects.json (the CLI's slug registry)
// and falls back to a slugified basename if the project isn't registered.
func geminiProjectSlug(workDir string) string {
abs, err := filepath.Abs(workDir)
if err != nil {
abs = workDir
}
homeDir, err := os.UserHomeDir()
if err != nil {
return slugify(filepath.Base(abs))
}
// Read the Gemini CLI project registry
data, err := os.ReadFile(filepath.Join(homeDir, ".gemini", "projects.json"))
if err == nil {
var registry struct {
Projects map[string]string `json:"projects"`
}
if json.Unmarshal(data, &registry) == nil {
// Normalize path for lookup (Gemini CLI uses path.normalize)
normalized := filepath.Clean(abs)
if slug, ok := registry.Projects[normalized]; ok {
return slug
}
}
}
// Fallback: replicate Gemini CLI's slugify logic
return slugify(filepath.Base(abs))
}
// slugify replicates the Gemini CLI's slug generation:
// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens.
func slugify(s string) string {
s = strings.ToLower(s)
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteByte('-')
}
}
// Collapse consecutive hyphens and trim
result := b.String()
for strings.Contains(result, "--") {
result = strings.ReplaceAll(result, "--", "-")
}
result = strings.Trim(result, "-")
if result == "" {
result = "project"
}
return result
}
// sessionFile represents the JSON structure of a Gemini CLI session file.
type sessionFile struct {
SessionID string `json:"sessionId"`
ProjectHash string `json:"projectHash"`
StartTime time.Time `json:"startTime"`
LastUpdated time.Time `json:"lastUpdated"`
Messages []sessionMessage `json:"messages"`
Kind string `json:"kind"`
}
// sessionMessage represents a message in the Gemini session file.
// The Content field is flexible: Gemini CLI can serialize it as either
// a plain string or an array of {text: "..."} parts.
type sessionMessage struct {
Type string `json:"type"`
RawContent json.RawMessage `json:"content"`
}
// textContent extracts text from the flexible content field.
func (m *sessionMessage) textContent() string {
if len(m.RawContent) == 0 {
return ""
}
// Try as plain string first
var s string
if json.Unmarshal(m.RawContent, &s) == nil {
return s
}
// Try as array of {text: "..."} parts
var parts []struct {
Text string `json:"text"`
}
if json.Unmarshal(m.RawContent, &parts) == nil {
var texts []string
for _, p := range parts {
if p.Text != "" {
texts = append(texts, p.Text)
}
}
return strings.Join(texts, "\n")
}
return ""
}
func listGeminiSessions(workDir string) ([]core.AgentSessionInfo, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("gemini: cannot determine home dir: %w", err)
}
slug := geminiProjectSlug(workDir)
chatsDir := filepath.Join(homeDir, ".gemini", "tmp", slug, "chats")
entries, err := os.ReadDir(chatsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("gemini: read chats dir: %w", err)
}
var sessions []core.AgentSessionInfo
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(chatsDir, entry.Name()))
if err != nil {
continue
}
var sf sessionFile
if json.Unmarshal(data, &sf) != nil || sf.SessionID == "" {
continue
}
// Skip subagent sessions (internal agent-spawned sessions)
if sf.Kind == "subagent" {
continue
}
// Skip sessions with no user messages
hasUserMsg := false
for _, msg := range sf.Messages {
if msg.Type == "user" {
hasUserMsg = true
break
}
}
if !hasUserMsg {
continue
}
summary := extractSessionSummary(&sf)
if utf8.RuneCountInString(summary) > 60 {
summary = string([]rune(summary)[:60]) + "..."
}
msgCount := len(sf.Messages)
modTime := sf.LastUpdated
if modTime.IsZero() {
modTime = sf.StartTime
}
sessions = append(sessions, core.AgentSessionInfo{
ID: sf.SessionID,
Summary: summary,
MessageCount: msgCount,
ModifiedAt: modTime,
})
}
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
})
return sessions, nil
}
// extractSessionSummary picks the first meaningful user text as the session summary.
func extractSessionSummary(sf *sessionFile) string {
for _, msg := range sf.Messages {
if msg.Type != "user" {
continue
}
text := strings.TrimSpace(msg.textContent())
if text == "" {
continue
}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "{") && strings.HasSuffix(line, "}") {
continue
}
return line
}
}
if len(sf.SessionID) > 12 {
return sf.SessionID[:12] + "..."
}
return sf.SessionID
}
+57
View File
@@ -0,0 +1,57 @@
package gemini
import (
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestConfiguredModels_BoundaryConditions(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{Models: []core.ModelOption{{Name: "first"}}},
{Models: []core.ModelOption{{Name: "second"}}},
},
}
tests := []struct {
name string
activeIdx int
wantNil bool
wantName string
}{
{name: "negative index", activeIdx: -1, wantNil: true},
{name: "out of range", activeIdx: 2, wantNil: true},
{name: "valid index", activeIdx: 1, wantName: "second"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a.activeIdx = tt.activeIdx
got := a.configuredModels()
if tt.wantNil {
if got != nil {
t.Fatalf("configuredModels() = %v, want nil", got)
}
return
}
if len(got) != 1 || got[0].Name != tt.wantName {
t.Fatalf("configuredModels() = %v, want %q", got, tt.wantName)
}
})
}
}
func TestGetModel_PrefersActiveProviderModel(t *testing.T) {
a := &Agent{
model: "gemini-2.5-flash",
providers: []core.ProviderConfig{
{Name: "google", Model: "gemini-2.5-pro"},
},
activeIdx: 0,
}
if got := a.GetModel(); got != "gemini-2.5-pro" {
t.Fatalf("GetModel() = %q, want gemini-2.5-pro", got)
}
}
+698
View File
@@ -0,0 +1,698 @@
package gemini
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
// geminiSession manages multi-turn conversations with the Gemini CLI.
// Each Send() launches a new `gemini -p - --output-format stream-json` process
// with --resume for conversation continuity. The prompt is passed via stdin
// (using -p - flag) to preserve newlines in multi-line messages.
type geminiSession struct {
cmd string
workDir string
model string
mode string
timeout time.Duration
extraEnv []string
events chan core.Event
chatID atomic.Value // stores string — Gemini session ID
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
pendingMsgs []string // buffered assistant messages awaiting classification
}
func newGeminiSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*geminiSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
gs := &geminiSession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
timeout: timeout,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
gs.alive.Store(true)
if resumeID != "" && resumeID != core.ContinueSession {
gs.chatID.Store(resumeID)
}
return gs, nil
}
func (gs *geminiSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) (err error) {
if !gs.alive.Load() {
return fmt.Errorf("session is closed")
}
// Save images and files into the workspace so Gemini CLI tools can access them.
attachDir := filepath.Join(gs.workDir, ".cc-connect", "attachments")
if (len(images) > 0 || len(files) > 0) && os.MkdirAll(attachDir, 0o755) != nil {
attachDir = os.TempDir()
}
var imageRefs []string
for i, img := range images {
ext := ".png"
switch img.MimeType {
case "image/jpeg":
ext = ".jpg"
case "image/gif":
ext = ".gif"
case "image/webp":
ext = ".webp"
}
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
fpath := filepath.Join(attachDir, fname)
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
slog.Warn("geminiSession: failed to save image", "error", err)
continue
}
imageRefs = append(imageRefs, fpath)
}
var fileRefs []string
for i, f := range files {
fname := filepath.Base(f.FileName)
if fname == "" || fname == "." || fname == ".." {
fname = fmt.Sprintf("file_%d_%d", time.Now().UnixMilli(), i)
}
fpath := filepath.Join(attachDir, fname)
if err := os.WriteFile(fpath, f.Data, 0o644); err != nil {
slog.Warn("geminiSession: failed to save file", "error", err)
continue
}
fileRefs = append(fileRefs, fpath)
}
chatID := gs.CurrentSessionID()
isResume := chatID != ""
args := []string{
"--output-format", "stream-json",
}
switch gs.mode {
case "yolo":
args = append(args, "-y")
case "auto_edit":
args = append(args, "--approval-mode", "auto_edit")
case "plan":
args = append(args, "--approval-mode", "plan")
}
if isResume {
args = append(args, "--resume", chatID)
}
if gs.model != "" {
args = append(args, "-m", gs.model)
}
// Build prompt with explicit file path references so Gemini can find them.
fullPrompt := prompt
if len(imageRefs) > 0 {
if fullPrompt == "" {
fullPrompt = "Please analyze the attached image(s)."
}
fullPrompt += "\n\n[Attached images saved at: " + strings.Join(imageRefs, ", ") + "]"
}
if len(fileRefs) > 0 {
if fullPrompt == "" {
fullPrompt = "Please analyze the attached file(s)."
}
fullPrompt += "\n\n[Attached files saved at: " + strings.Join(fileRefs, ", ") + "]"
}
// Pass prompt via stdin instead of -p flag to preserve newlines.
// The -p flag can truncate at newline characters in some Gemini CLI versions.
args = append(args, "-p", "-")
// Add timeout for each turn to prevent hanging processes
var cancel context.CancelFunc
var ctx context.Context
if gs.timeout > 0 {
ctx, cancel = context.WithTimeout(gs.ctx, gs.timeout)
} else {
ctx, cancel = context.WithCancel(gs.ctx)
}
// ensure cancel is called on early return errors
started := false
defer func() {
if !started {
cancel()
}
}()
slog.Debug("geminiSession: launching", "resume", isResume, "args", core.RedactArgs(args))
cmd := exec.CommandContext(ctx, gs.cmd, args...)
// Set a short WaitDelay to ensure I/O goroutines don't block for long after the context is done
cmd.WaitDelay = 1 * time.Second
cmd.Dir = gs.workDir
env := os.Environ()
if len(gs.extraEnv) > 0 {
env = core.MergeEnv(env, gs.extraEnv)
}
cmd.Env = env
cmd.Stdin = strings.NewReader(fullPrompt)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("geminiSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("geminiSession: start: %w", err)
}
started = true
gs.wg.Add(1)
go func() {
defer cancel()
gs.readLoop(ctx, cmd, stdout, &stderrBuf, append(imageRefs, fileRefs...))
}()
return nil
}
func (gs *geminiSession) readLoop(ctx context.Context, cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer, tempImages []string) {
defer gs.wg.Done()
defer func() {
// Clean up temp image files
for _, f := range tempImages {
os.Remove(f)
}
if err := cmd.Wait(); err != nil {
stderrMsg := strings.TrimSpace(stderrBuf.String())
if stderrMsg != "" {
slog.Error("geminiSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
return
}
}
}
}()
// Unblock scanner if context is canceled
go func() {
<-ctx.Done()
stdout.Close()
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
slog.Debug("geminiSession: raw", "line", truncate(line, 500))
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
slog.Debug("geminiSession: non-JSON line", "line", line)
continue
}
gs.handleEvent(raw)
}
if err := scanner.Err(); err != nil {
slog.Error("geminiSession: scanner error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
return
}
}
}
// Gemini CLI stream-json event types:
//
// init — session_id, model
// message — role (user/assistant), content, delta
// tool_use — tool_name, tool_id, parameters
// tool_result — tool_id, status, output, error
// error — severity, message
// result — status, stats (final event)
func (gs *geminiSession) handleEvent(raw map[string]any) {
eventType, _ := raw["type"].(string)
switch eventType {
case "init":
gs.handleInit(raw)
case "message":
gs.handleMessage(raw)
case "tool_use":
gs.handleToolUse(raw)
case "tool_result":
gs.handleToolResult(raw)
case "error":
gs.handleError(raw)
case "result":
gs.handleResult(raw)
default:
slog.Debug("geminiSession: unhandled event", "type", eventType)
}
}
func (gs *geminiSession) handleInit(raw map[string]any) {
sid, _ := raw["session_id"].(string)
model, _ := raw["model"].(string)
if sid != "" {
gs.chatID.Store(sid)
slog.Debug("geminiSession: session init", "session_id", sid, "model", model)
evt := core.Event{Type: core.EventText, SessionID: sid, Content: "", ToolName: model}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
return
}
}
}
func (gs *geminiSession) handleMessage(raw map[string]any) {
role, _ := raw["role"].(string)
content, _ := raw["content"].(string)
if role == "user" || content == "" {
return
}
// Delta messages are incremental streaming fragments — emit immediately
// as EventText so engine's stream preview can update in real time.
// Non-delta messages (complete text) are buffered for later classification
// (thinking vs final text) based on what event follows.
delta, _ := raw["delta"].(bool)
if delta {
evt := core.Event{Type: core.EventText, Content: content}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
}
return
}
gs.pendingMsgs = append(gs.pendingMsgs, content)
}
func (gs *geminiSession) handleToolUse(raw map[string]any) {
gs.flushPendingAsThinking()
toolName, _ := raw["tool_name"].(string)
toolID, _ := raw["tool_id"].(string)
params, _ := raw["parameters"].(map[string]any)
input := formatToolParams(toolName, params)
slog.Debug("geminiSession: tool_use", "tool", toolName, "id", toolID)
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
return
}
}
func (gs *geminiSession) handleToolResult(raw map[string]any) {
toolID, _ := raw["tool_id"].(string)
status, _ := raw["status"].(string)
output, _ := raw["output"].(string)
slog.Debug("geminiSession: tool_result", "tool_id", toolID, "status", status)
if status == "error" {
errObj, _ := raw["error"].(map[string]any)
if errObj != nil {
errMsg, _ := errObj["message"].(string)
if errMsg != "" {
output = "Error: " + errMsg
}
}
}
if output != "" {
evt := core.Event{Type: core.EventToolResult, ToolName: toolID, Content: truncate(output, 500)}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
return
}
}
}
func (gs *geminiSession) handleError(raw map[string]any) {
severity, _ := raw["severity"].(string)
message, _ := raw["message"].(string)
if message != "" {
slog.Warn("geminiSession: error event", "severity", severity, "message", message)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("[%s] %s", severity, message)}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
return
}
}
}
func (gs *geminiSession) handleResult(raw map[string]any) {
gs.flushPendingAsText()
status, _ := raw["status"].(string)
var errMsg string
if status == "error" {
errObj, _ := raw["error"].(map[string]any)
if errObj != nil {
errMsg, _ = errObj["message"].(string)
}
}
sid := gs.CurrentSessionID()
if errMsg != "" {
evt := core.Event{Type: core.EventResult, Content: errMsg, SessionID: sid, Done: true, Error: fmt.Errorf("%s", errMsg)}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
return
}
} else {
evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
return
}
}
}
func (gs *geminiSession) flushPendingAsThinking() {
if len(gs.pendingMsgs) == 0 {
return
}
text := strings.Join(gs.pendingMsgs, "")
gs.pendingMsgs = gs.pendingMsgs[:0]
if text != "" {
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
}
}
}
func (gs *geminiSession) flushPendingAsText() {
if len(gs.pendingMsgs) == 0 {
return
}
text := strings.Join(gs.pendingMsgs, "")
gs.pendingMsgs = gs.pendingMsgs[:0]
if text != "" {
evt := core.Event{Type: core.EventText, Content: text}
select {
case gs.events <- evt:
case <-gs.ctx.Done():
}
}
}
// RespondPermission is a no-op — Gemini CLI permissions are handled via -y / --approval-mode flags.
func (gs *geminiSession) RespondPermission(_ string, _ core.PermissionResult) error {
return nil
}
func (gs *geminiSession) Events() <-chan core.Event {
return gs.events
}
func (gs *geminiSession) CurrentSessionID() string {
v, _ := gs.chatID.Load().(string)
return v
}
func (gs *geminiSession) Alive() bool {
return gs.alive.Load()
}
func (gs *geminiSession) Close() error {
gs.alive.Store(false)
gs.cancel()
done := make(chan struct{})
go func() {
gs.wg.Wait()
close(done)
}()
select {
case <-done:
close(gs.events)
case <-time.After(8 * time.Second):
slog.Warn("geminiSession: close timed out, abandoning wg.Wait")
}
return nil
}
// formatToolParams extracts a human-readable summary from tool parameters.
func formatToolParams(toolName string, params map[string]any) string {
if params == nil {
return ""
}
switch toolName {
case "shell", "run_shell_command", "Bash":
if cmd, ok := params["command"].(string); ok {
return cmd
}
case "write_file", "WriteFile":
fp, _ := params["file_path"].(string)
if fp == "" {
fp, _ = params["path"].(string)
}
if fp != "" {
if content, ok := params["content"].(string); ok && content != "" {
return "`" + fp + "`\n```\n" + content + "\n```"
}
return fp
}
case "replace", "ReplaceInFile":
fp, _ := params["file_path"].(string)
if fp == "" {
fp, _ = params["path"].(string)
}
if fp != "" {
old, _ := params["old_string"].(string)
new_, _ := params["new_string"].(string)
if old == "" {
old, _ = params["old_str"].(string)
new_, _ = params["new_str"].(string)
}
if old != "" || new_ != "" {
diff := computeLineDiff(old, new_)
return "`" + fp + "`\n```diff\n" + diff + "\n```"
}
return fp
}
case "read_file", "ReadFile":
if p, ok := params["file_path"].(string); ok {
return p
}
if p, ok := params["path"].(string); ok {
return p
}
case "list_directory", "ListDirectory":
if p, ok := params["dir_path"].(string); ok {
return p
}
if p, ok := params["path"].(string); ok {
return p
}
if p, ok := params["directory"].(string); ok {
return p
}
case "web_fetch", "WebFetch":
if p, ok := params["prompt"].(string); ok {
return p
}
if u, ok := params["url"].(string); ok {
return u
}
case "google_web_search", "GoogleWebSearch":
if q, ok := params["query"].(string); ok {
return q
}
case "activate_skill":
if n, ok := params["name"].(string); ok {
return n
}
case "search_code", "SearchCode", "Glob", "glob", "Grep", "grep_search":
if q, ok := params["query"].(string); ok {
return q
}
if p, ok := params["pattern"].(string); ok {
return p
}
case "save_memory":
if f, ok := params["fact"].(string); ok {
return f
}
case "ask_user":
if qs, ok := params["questions"].([]any); ok && len(qs) > 0 {
if q0, ok := qs[0].(map[string]any); ok {
if question, ok := q0["question"].(string); ok {
return question
}
}
}
case "enter_plan_mode":
if r, ok := params["reason"].(string); ok {
return r
}
case "exit_plan_mode":
if p, ok := params["plan_path"].(string); ok {
return p
}
}
// Fallback: format as key: value pairs for readability
var parts []string
for k, v := range params {
switch val := v.(type) {
case string:
parts = append(parts, k+": "+val)
default:
j, _ := json.Marshal(val)
parts = append(parts, k+": "+string(j))
}
}
return strings.Join(parts, ", ")
}
// computeLineDiff computes a minimal unified-style diff between old and new text.
// It finds common prefix/suffix lines and shows only the changed lines with
// up to 1 line of surrounding context. Unchanged context lines are prefixed
// with " ", removed lines with "- ", and added lines with "+ ".
func computeLineDiff(old, new_ string) string {
oldLines := strings.Split(old, "\n")
newLines := strings.Split(new_, "\n")
// Find common prefix lines
prefixLen := 0
minLen := len(oldLines)
if len(newLines) < minLen {
minLen = len(newLines)
}
for prefixLen < minLen && oldLines[prefixLen] == newLines[prefixLen] {
prefixLen++
}
// Find common suffix lines (not overlapping with prefix)
suffixLen := 0
for suffixLen < len(oldLines)-prefixLen && suffixLen < len(newLines)-prefixLen {
oi := len(oldLines) - 1 - suffixLen
ni := len(newLines) - 1 - suffixLen
if oldLines[oi] != newLines[ni] {
break
}
suffixLen++
}
// No actual changes
if prefixLen+suffixLen >= len(oldLines) && prefixLen+suffixLen >= len(newLines) {
return ""
}
// If everything differs (no common lines), show full old/new
if prefixLen == 0 && suffixLen == 0 {
var sb strings.Builder
for _, l := range oldLines {
sb.WriteString("- " + l + "\n")
}
for _, l := range newLines {
sb.WriteString("+ " + l + "\n")
}
return strings.TrimRight(sb.String(), "\n")
}
var sb strings.Builder
const contextN = 1
// Context: tail of common prefix
ctxStart := prefixLen - contextN
if ctxStart < 0 {
ctxStart = 0
}
if ctxStart > 0 {
sb.WriteString(" ...\n")
}
for i := ctxStart; i < prefixLen; i++ {
sb.WriteString(" " + oldLines[i] + "\n")
}
// Removed lines
for i := prefixLen; i < len(oldLines)-suffixLen; i++ {
sb.WriteString("- " + oldLines[i] + "\n")
}
// Added lines
for i := prefixLen; i < len(newLines)-suffixLen; i++ {
sb.WriteString("+ " + newLines[i] + "\n")
}
// Context: head of common suffix
suffStart := len(oldLines) - suffixLen
suffEnd := suffStart + contextN
if suffEnd > len(oldLines) {
suffEnd = len(oldLines)
}
for i := suffStart; i < suffEnd; i++ {
sb.WriteString(" " + oldLines[i] + "\n")
}
if suffEnd < len(oldLines) {
sb.WriteString(" ...")
}
return strings.TrimRight(sb.String(), "\n")
}
func truncate(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes]) + "..."
}
+509
View File
@@ -0,0 +1,509 @@
package gemini
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
)
// sanitizeFileName mirrors the logic in geminiSession.Send for file name sanitization.
func sanitizeFileName(fileName string, index int) string {
fname := filepath.Base(fileName)
if fname == "" || fname == "." || fname == ".." {
fname = fmt.Sprintf("cc-connect-file-%d", index)
}
return fname
}
func TestSanitizeFileName(t *testing.T) {
tests := []struct {
name string
input string
index int
wantSafe bool
want string
}{
{"normal file", "report.pdf", 0, true, "report.pdf"},
{"path traversal", "../../etc/passwd", 0, true, "passwd"},
{"deep traversal", "../../../tmp/evil.sh", 1, true, "evil.sh"},
{"absolute path", "/etc/shadow", 0, true, "shadow"},
{"empty name", "", 2, true, "cc-connect-file-2"},
{"dot only", ".", 3, true, "cc-connect-file-3"},
{"double dot", "..", 4, true, "cc-connect-file-4"},
{"subdir file", "subdir/file.txt", 0, true, "file.txt"},
{"slash path", "dir/subdir/evil.dll", 0, true, "evil.dll"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeFileName(tt.input, tt.index)
if tt.want != "" && got != tt.want {
t.Errorf("sanitizeFileName(%q, %d) = %q, want %q", tt.input, tt.index, got, tt.want)
}
if strings.Contains(got, "..") || strings.Contains(got, "/") {
t.Errorf("sanitizeFileName(%q, %d) = %q — still contains path traversal chars", tt.input, tt.index, got)
}
})
}
}
// drainEvents reads all events from the channel until it blocks for the given timeout.
func drainEvents(ch <-chan core.Event, timeout time.Duration) []core.Event {
var events []core.Event
for {
select {
case evt, ok := <-ch:
if !ok {
return events
}
events = append(events, evt)
case <-time.After(timeout):
return events
}
}
}
func TestHandleMessage_DeltaEmitsEventTextImmediately(t *testing.T) {
gs := &geminiSession{
events: make(chan core.Event, 64),
ctx: context.Background(),
}
// Delta message should emit EventText immediately
gs.handleEvent(map[string]any{
"type": "message",
"role": "assistant",
"content": "Hello ",
"delta": true,
})
gs.handleEvent(map[string]any{
"type": "message",
"role": "assistant",
"content": "world!",
"delta": true,
})
events := drainEvents(gs.events, 50*time.Millisecond)
if len(events) != 2 {
t.Fatalf("expected 2 events, got %d", len(events))
}
if events[0].Type != core.EventText || events[0].Content != "Hello " {
t.Errorf("event 0: expected EventText 'Hello ', got %v %q", events[0].Type, events[0].Content)
}
if events[1].Type != core.EventText || events[1].Content != "world!" {
t.Errorf("event 1: expected EventText 'world!', got %v %q", events[1].Type, events[1].Content)
}
// No pending messages should be buffered
if len(gs.pendingMsgs) != 0 {
t.Errorf("expected 0 pending messages, got %d", len(gs.pendingMsgs))
}
}
func TestHandleMessage_NonDeltaBuffered(t *testing.T) {
gs := &geminiSession{
events: make(chan core.Event, 64),
ctx: context.Background(),
}
// Non-delta message should be buffered (no immediate event)
gs.handleEvent(map[string]any{
"type": "message",
"role": "assistant",
"content": "Thinking about this...",
})
events := drainEvents(gs.events, 50*time.Millisecond)
if len(events) != 0 {
t.Fatalf("expected 0 immediate events for non-delta message, got %d", len(events))
}
if len(gs.pendingMsgs) != 1 || gs.pendingMsgs[0] != "Thinking about this..." {
t.Errorf("expected 1 pending message, got %v", gs.pendingMsgs)
}
}
func TestHandleMessage_NonDeltaFlushedAsThinkingOnToolUse(t *testing.T) {
gs := &geminiSession{
events: make(chan core.Event, 64),
ctx: context.Background(),
}
// Buffer a non-delta message
gs.handleEvent(map[string]any{
"type": "message",
"role": "assistant",
"content": "Let me check...",
})
// tool_use should flush it as thinking
gs.handleEvent(map[string]any{
"type": "tool_use",
"tool_name": "shell",
"tool_id": "t1",
"parameters": map[string]any{
"command": "ls",
},
})
events := drainEvents(gs.events, 50*time.Millisecond)
if len(events) != 2 {
t.Fatalf("expected 2 events, got %d", len(events))
}
if events[0].Type != core.EventThinking || events[0].Content != "Let me check..." {
t.Errorf("event 0: expected EventThinking, got %v %q", events[0].Type, events[0].Content)
}
if events[1].Type != core.EventToolUse {
t.Errorf("event 1: expected EventToolUse, got %v", events[1].Type)
}
}
func TestHandleMessage_NonDeltaFlushedAsTextOnResult(t *testing.T) {
gs := &geminiSession{
events: make(chan core.Event, 64),
ctx: context.Background(),
}
// Buffer a non-delta message
gs.handleEvent(map[string]any{
"type": "message",
"role": "assistant",
"content": "Here is the result.",
})
// result should flush it as text
gs.handleEvent(map[string]any{
"type": "result",
"status": "success",
})
events := drainEvents(gs.events, 50*time.Millisecond)
if len(events) != 2 {
t.Fatalf("expected 2 events, got %d", len(events))
}
if events[0].Type != core.EventText || events[0].Content != "Here is the result." {
t.Errorf("event 0: expected EventText, got %v %q", events[0].Type, events[0].Content)
}
if events[1].Type != core.EventResult {
t.Errorf("event 1: expected EventResult, got %v", events[1].Type)
}
}
func TestHandleMessage_UserMessagesIgnored(t *testing.T) {
gs := &geminiSession{
events: make(chan core.Event, 64),
ctx: context.Background(),
}
gs.handleEvent(map[string]any{
"type": "message",
"role": "user",
"content": "Hello agent",
"delta": true,
})
events := drainEvents(gs.events, 50*time.Millisecond)
if len(events) != 0 {
t.Fatalf("expected 0 events for user message, got %d", len(events))
}
}
func TestHandleMessage_EmptyContentIgnored(t *testing.T) {
gs := &geminiSession{
events: make(chan core.Event, 64),
ctx: context.Background(),
}
gs.handleEvent(map[string]any{
"type": "message",
"role": "assistant",
"content": "",
"delta": true,
})
events := drainEvents(gs.events, 50*time.Millisecond)
if len(events) != 0 {
t.Fatalf("expected 0 events for empty content, got %d", len(events))
}
}
func TestHandleMessage_MixedDeltaAndNonDelta(t *testing.T) {
gs := &geminiSession{
events: make(chan core.Event, 64),
ctx: context.Background(),
}
// Simulate a realistic Gemini CLI output sequence:
// 1. non-delta thinking message
// 2. tool_use flushes thinking
// 3. tool_result
// 4. delta streaming responses
// 5. result
gs.handleEvent(map[string]any{
"type": "message",
"role": "assistant",
"content": "Let me look at the files.",
})
gs.handleEvent(map[string]any{
"type": "tool_use",
"tool_name": "shell",
"tool_id": "t1",
"parameters": map[string]any{"command": "ls"},
})
gs.handleEvent(map[string]any{
"type": "tool_result",
"tool_id": "t1",
"status": "success",
"output": "file1.txt\nfile2.txt",
})
gs.handleEvent(map[string]any{
"type": "message",
"role": "assistant",
"content": "Here are ",
"delta": true,
})
gs.handleEvent(map[string]any{
"type": "message",
"role": "assistant",
"content": "the files.",
"delta": true,
})
gs.handleEvent(map[string]any{
"type": "result",
"status": "success",
})
events := drainEvents(gs.events, 50*time.Millisecond)
// Expected sequence: EventThinking, EventToolUse, EventToolResult, EventText, EventText, EventResult
if len(events) != 6 {
var types []string
for _, e := range events {
types = append(types, string(e.Type))
}
t.Fatalf("expected 6 events, got %d: %v", len(events), types)
}
expects := []struct {
typ core.EventType
content string
}{
{core.EventThinking, "Let me look at the files."},
{core.EventToolUse, ""},
{core.EventToolResult, ""},
{core.EventText, "Here are "},
{core.EventText, "the files."},
{core.EventResult, ""},
}
for i, exp := range expects {
if events[i].Type != exp.typ {
t.Errorf("event %d: expected type %v, got %v", i, exp.typ, events[i].Type)
}
if exp.content != "" && events[i].Content != exp.content {
t.Errorf("event %d: expected content %q, got %q", i, exp.content, events[i].Content)
}
}
}
func TestHandleInit_StoresSessionID(t *testing.T) {
gs := &geminiSession{
events: make(chan core.Event, 64),
ctx: context.Background(),
}
gs.handleEvent(map[string]any{
"type": "init",
"session_id": "abc123",
"model": "gemini-2.0-flash",
})
if sid := gs.CurrentSessionID(); sid != "abc123" {
t.Errorf("expected session_id abc123, got %q", sid)
}
events := drainEvents(gs.events, 50*time.Millisecond)
if len(events) != 1 || events[0].Type != core.EventText {
t.Errorf("expected 1 EventText from init, got %v", events)
}
}
func TestHandleError_EmitsEventError(t *testing.T) {
gs := &geminiSession{
events: make(chan core.Event, 64),
ctx: context.Background(),
}
gs.handleEvent(map[string]any{
"type": "error",
"severity": "error",
"message": "something broke",
})
events := drainEvents(gs.events, 50*time.Millisecond)
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].Type != core.EventError {
t.Errorf("expected EventError, got %v", events[0].Type)
}
if !strings.Contains(events[0].Error.Error(), "something broke") {
t.Errorf("error should contain message, got %v", events[0].Error)
}
}
func TestFormatToolParams(t *testing.T) {
tests := []struct {
name string
toolName string
params map[string]any
want string
}{
{"shell command", "shell", map[string]any{"command": "ls -la"}, "ls -la"},
{"Bash command", "Bash", map[string]any{"command": "echo hello"}, "echo hello"},
{"write file path only", "write_file", map[string]any{"file_path": "/tmp/test.txt"}, "/tmp/test.txt"},
{"write file with content", "write_file", map[string]any{"file_path": "/tmp/test.txt", "content": "hello world"}, "`/tmp/test.txt`\n```\nhello world\n```"},
{"write file full content", "write_file", map[string]any{"file_path": "/tmp/test.txt", "content": strings.Repeat("x", 200)}, "`/tmp/test.txt`\n```\n" + strings.Repeat("x", 200) + "\n```"},
{"read file path", "read_file", map[string]any{"path": "/tmp/test.txt"}, "/tmp/test.txt"},
{"replace single line", "replace", map[string]any{"file_path": "/tmp/test.go", "old_string": "foo", "new_string": "bar"}, "`/tmp/test.go`\n```diff\n- foo\n+ bar\n```"},
{"replace with context", "replace", map[string]any{"file_path": "/tmp/test.go", "old_string": "func foo() {\n return 1\n}", "new_string": "func foo() {\n return 2\n}"}, "`/tmp/test.go`\n```diff\n func foo() {\n- return 1\n+ return 2\n }\n```"},
{"list directory path", "list_directory", map[string]any{"path": "/home/user"}, "/home/user"},
{"list directory dir_path", "list_directory", map[string]any{"dir_path": "/home/user"}, "/home/user"},
{"web fetch prompt", "web_fetch", map[string]any{"prompt": "https://example.com fetch this page"}, "https://example.com fetch this page"},
{"web fetch url fallback", "web_fetch", map[string]any{"url": "https://example.com"}, "https://example.com"},
{"search query", "google_web_search", map[string]any{"query": "golang testing"}, "golang testing"},
{"activate skill", "activate_skill", map[string]any{"name": "obsidian-markdown"}, "obsidian-markdown"},
{"search code", "search_code", map[string]any{"query": "func main"}, "func main"},
{"Grep pattern", "Grep", map[string]any{"pattern": "TODO"}, "TODO"},
{"grep_search pattern", "grep_search", map[string]any{"pattern": "TODO"}, "TODO"},
{"glob lowercase", "glob", map[string]any{"pattern": "*.go"}, "*.go"},
{"save memory", "save_memory", map[string]any{"fact": "user prefers dark mode"}, "user prefers dark mode"},
{"ask user", "ask_user", map[string]any{"questions": []any{map[string]any{"question": "Which DB?", "header": "DB", "type": "choice"}}}, "Which DB?"},
{"enter plan mode", "enter_plan_mode", map[string]any{"reason": "complex task"}, "complex task"},
{"exit plan mode", "exit_plan_mode", map[string]any{"plan_path": "/tmp/plan.md"}, "/tmp/plan.md"},
{"nil params", "shell", nil, ""},
{"unknown single key", "custom_tool", map[string]any{"key": "value"}, "key: value"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatToolParams(tt.toolName, tt.params)
if got != tt.want {
t.Errorf("formatToolParams(%q, %v) = %q, want %q", tt.toolName, tt.params, got, tt.want)
}
})
}
}
func TestSlugify(t *testing.T) {
tests := []struct {
input string
want string
}{
{"cc-connect", "cc-connect"},
{"Daily", "daily"},
{"My Project", "my-project"},
{"hello_world", "hello-world"},
{"Test.123", "test-123"},
{"---weird---", "weird"},
{"", "project"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := slugify(tt.input)
if got != tt.want {
t.Errorf("slugify(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestSessionMessage_TextContent(t *testing.T) {
// Test plain string content
m1 := sessionMessage{Type: "user", RawContent: json.RawMessage(`"hello world"`)}
if got := m1.textContent(); got != "hello world" {
t.Errorf("string content: got %q, want %q", got, "hello world")
}
// Test array of parts content
m2 := sessionMessage{Type: "user", RawContent: json.RawMessage(`[{"text":"line 1"},{"text":"line 2"}]`)}
if got := m2.textContent(); got != "line 1\nline 2" {
t.Errorf("array content: got %q, want %q", got, "line 1\nline 2")
}
// Test empty content
m3 := sessionMessage{Type: "user", RawContent: nil}
if got := m3.textContent(); got != "" {
t.Errorf("nil content: got %q, want empty", got)
}
}
func TestComputeLineDiff(t *testing.T) {
tests := []struct {
name string
old string
new_ string
want string
}{
{
"single line fully different",
"foo", "bar",
"- foo\n+ bar",
},
{
"common prefix and suffix",
"func foo() {\n return 1\n}",
"func foo() {\n return 2\n}",
" func foo() {\n- return 1\n+ return 2\n }",
},
{
"large context ellipsis",
"a\nb\nc\nd\nold\ne\nf\ng\nh",
"a\nb\nc\nd\nnew\ne\nf\ng\nh",
" ...\n d\n- old\n+ new\n e\n ...",
},
{
"common prefix only",
"header\nold1\nold2",
"header\nnew1",
" header\n- old1\n- old2\n+ new1",
},
{
"common suffix only",
"old1\nfooter",
"new1\nnew2\nfooter",
"- old1\n+ new1\n+ new2\n footer",
},
{
"identical",
"same\nlines",
"same\nlines",
"", // prefixLen covers all lines, no diff
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeLineDiff(tt.old, tt.new_)
if got != tt.want {
t.Errorf("computeLineDiff:\n old=%q\n new=%q\n got=%q\n want=%q", tt.old, tt.new_, got, tt.want)
}
})
}
}
func TestGeminiSession_ContinueSessionTreatedAsFresh(t *testing.T) {
s, err := newGeminiSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0)
if err != nil {
t.Fatalf("newGeminiSession: %v", err)
}
defer s.Close()
if got := s.CurrentSessionID(); got != "" {
t.Errorf("ContinueSession should be treated as fresh: chatID = %q, want empty", got)
}
}