初始化仓库
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"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("antigravity", New)
|
||||
}
|
||||
|
||||
// Agent drives the Antigravity CLI (agy) in headless mode.
|
||||
//
|
||||
// Modes (maps to agy approval and sandbox flags):
|
||||
// - "default": standard approval mode (prompt for each tool use)
|
||||
// - "yolo": auto-approve all tools (--dangerously-skip-permissions)
|
||||
// - "plan": read-only plan mode with terminal sandbox constraints (--sandbox)
|
||||
type Agent struct {
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
cmd string // CLI binary name, default "agy"
|
||||
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 = "agy"
|
||||
}
|
||||
|
||||
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("antigravity: 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("antigravity: %q CLI not found in PATH, install from: https://antigravity.google/docs/cli-overview", 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 "plan", "sandbox":
|
||||
return "plan"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) Name() string { return "antigravity" }
|
||||
|
||||
func (a *Agent) SetWorkDir(dir string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.workDir = dir
|
||||
slog.Info("antigravity: 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("antigravity: 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
|
||||
}
|
||||
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"},
|
||||
}
|
||||
}
|
||||
|
||||
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("antigravity: failed to fetch models", "error", err)
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
} `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 newAntigravitySession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout)
|
||||
}
|
||||
|
||||
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
return listAntigravitySessions(a.workDir)
|
||||
}
|
||||
|
||||
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("antigravity: cannot determine home dir: %w", err)
|
||||
}
|
||||
chatsDir := filepath.Join(homeDir, ".gemini", "tmp", antigravityProjectSlug(a.workDir), "chats")
|
||||
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(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
fpath := filepath.Join(chatsDir, entry.Name())
|
||||
file, err := os.Open(fpath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
if scanner.Scan() {
|
||||
var sf struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
if json.Unmarshal([]byte(scanner.Text()), &sf) == nil && sf.SessionID == sessionID {
|
||||
_ = file.Close()
|
||||
return os.Remove(fpath)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = file.Close()
|
||||
continue
|
||||
}
|
||||
_ = file.Close()
|
||||
}
|
||||
return fmt.Errorf("session file not found: %s", sessionID)
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() error { return nil }
|
||||
|
||||
func (a *Agent) SetMode(mode string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.mode = normalizeMode(mode)
|
||||
slog.Info("antigravity: 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: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
|
||||
{Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only plan mode in sandbox", DescZh: "只读沙箱规划模式"},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (a *Agent) CompressCommand() string { return "" }
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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("antigravity: provider cleared")
|
||||
return true
|
||||
}
|
||||
for i, p := range a.providers {
|
||||
if p.Name == name {
|
||||
a.activeIdx = i
|
||||
slog.Info("antigravity: 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
|
||||
}
|
||||
|
||||
func antigravityProjectSlug(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))
|
||||
}
|
||||
|
||||
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, ®istry) == nil {
|
||||
normalized := filepath.Clean(abs)
|
||||
if slug, ok := registry.Projects[normalized]; ok {
|
||||
return slug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slugify(filepath.Base(abs))
|
||||
}
|
||||
|
||||
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('-')
|
||||
}
|
||||
}
|
||||
result := b.String()
|
||||
for strings.Contains(result, "--") {
|
||||
result = strings.ReplaceAll(result, "--", "-")
|
||||
}
|
||||
result = strings.Trim(result, "-")
|
||||
if result == "" {
|
||||
result = "project"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type sessionFile struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ProjectHash string `json:"projectHash"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
type sessionMessagePart struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type sessionLine struct {
|
||||
Type string `json:"type"` // "user", "assistant"
|
||||
Content []sessionMessagePart `json:"content"`
|
||||
Set *struct {
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
} `json:"$set"`
|
||||
}
|
||||
|
||||
func listAntigravitySessions(workDir string) ([]core.AgentSessionInfo, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("antigravity: cannot determine home dir: %w", err)
|
||||
}
|
||||
|
||||
slug := antigravityProjectSlug(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("antigravity: read chats dir: %w", err)
|
||||
}
|
||||
|
||||
var sessions []core.AgentSessionInfo
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
|
||||
fpath := filepath.Join(chatsDir, entry.Name())
|
||||
file, err := os.Open(fpath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var sf sessionFile
|
||||
var summary string
|
||||
msgCount := 0
|
||||
hasUserMsg := false
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if lineNum == 0 {
|
||||
if err := json.Unmarshal([]byte(line), &sf); err != nil || sf.SessionID == "" {
|
||||
break
|
||||
}
|
||||
lineNum++
|
||||
continue
|
||||
}
|
||||
|
||||
var sl sessionLine
|
||||
if json.Unmarshal([]byte(line), &sl) == nil {
|
||||
if sl.Set != nil && !sl.Set.LastUpdated.IsZero() {
|
||||
sf.LastUpdated = sl.Set.LastUpdated
|
||||
} else if sl.Type != "" {
|
||||
msgCount++
|
||||
if sl.Type == "user" {
|
||||
hasUserMsg = true
|
||||
if summary == "" && len(sl.Content) > 0 {
|
||||
text := strings.TrimSpace(sl.Content[0].Text)
|
||||
for _, chunk := range strings.Split(text, "\n") {
|
||||
chunk = strings.TrimSpace(chunk)
|
||||
if chunk != "" {
|
||||
summary = chunk
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lineNum++
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = file.Close()
|
||||
continue
|
||||
}
|
||||
_ = file.Close()
|
||||
|
||||
if sf.SessionID == "" || sf.Kind == "subagent" || !hasUserMsg {
|
||||
continue
|
||||
}
|
||||
|
||||
if summary == "" {
|
||||
summary = sf.SessionID
|
||||
}
|
||||
if utf8.RuneCountInString(summary) > 60 {
|
||||
summary = string([]rune(summary)[:60]) + "..."
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package antigravity
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// antigravitySession manages multi-turn conversations with the Antigravity CLI (agy).
|
||||
type antigravitySession struct {
|
||||
cmd string
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
timeout time.Duration
|
||||
extraEnv []string
|
||||
events chan core.Event
|
||||
stdin io.WriteCloser
|
||||
stdinMu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
permReqID atomic.Value // stores string
|
||||
chatID atomic.Value // stores string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
alive atomic.Bool
|
||||
}
|
||||
|
||||
var permissionPromptPattern = regexp.MustCompile(`(?is)(allow|approve|permission).{0,400}(\(y/n\)|\(y\/n\)|\(y\/N\)|\(Y\/n\)|\[y\/n\]|\[y\/N\]|\[Y\/n\]|yes\/no)`)
|
||||
|
||||
func newAntigravitySession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*antigravitySession, error) {
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
as := &antigravitySession{
|
||||
cmd: cmd,
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
timeout: timeout,
|
||||
extraEnv: extraEnv,
|
||||
events: make(chan core.Event, 64),
|
||||
ctx: sessionCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
as.alive.Store(true)
|
||||
|
||||
if resumeID != "" && resumeID != core.ContinueSession {
|
||||
as.chatID.Store(resumeID)
|
||||
}
|
||||
|
||||
return as, nil
|
||||
}
|
||||
|
||||
func (as *antigravitySession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
if !as.alive.Load() {
|
||||
return fmt.Errorf("session is closed")
|
||||
}
|
||||
|
||||
// Capture existing chat logs so we can identify a new session on first turn
|
||||
preEntries := make(map[string]bool)
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
slug := antigravityProjectSlug(as.workDir)
|
||||
chatsDir := filepath.Join(homeDir, ".gemini", "tmp", slug, "chats")
|
||||
if entries, err := os.ReadDir(chatsDir); err == nil {
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".jsonl") {
|
||||
preEntries[entry.Name()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save images and files into the workspace
|
||||
attachDir := filepath.Join(as.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, 0o600); err == nil {
|
||||
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, 0o600); err == nil {
|
||||
fileRefs = append(fileRefs, fpath)
|
||||
}
|
||||
}
|
||||
|
||||
chatID := as.CurrentSessionID()
|
||||
isResume := chatID != ""
|
||||
|
||||
// Attach image and file references to prompt
|
||||
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, ", ") + "]"
|
||||
}
|
||||
args := buildAntigravityArgs(chatID, isResume, as.mode, fullPrompt)
|
||||
if strings.TrimSpace(as.model) != "" {
|
||||
slog.Warn("antigravitySession: model is configured but ignored because agy does not support --model yet", "model", as.model)
|
||||
}
|
||||
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
if as.timeout > 0 {
|
||||
ctx, cancel = context.WithTimeout(as.ctx, as.timeout)
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(as.ctx)
|
||||
}
|
||||
|
||||
started := false
|
||||
defer func() {
|
||||
if !started {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Debug("antigravitySession: launching", "resume", isResume, "args", core.RedactArgs(args))
|
||||
cmd := exec.CommandContext(ctx, as.cmd, args...)
|
||||
cmd.WaitDelay = 1 * time.Second
|
||||
cmd.Dir = as.workDir
|
||||
env := os.Environ()
|
||||
if len(as.extraEnv) > 0 {
|
||||
env = core.MergeEnv(env, as.extraEnv)
|
||||
}
|
||||
cmd.Env = env
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("antigravitySession: stdout pipe: %w", err)
|
||||
}
|
||||
var stdin io.WriteCloser
|
||||
if usesInteractivePermission(as.mode) {
|
||||
stdin, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("antigravitySession: stdin pipe: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("antigravitySession: start: %w", err)
|
||||
}
|
||||
as.stdinMu.Lock()
|
||||
as.stdin = stdin
|
||||
as.stdinMu.Unlock()
|
||||
|
||||
started = true
|
||||
as.wg.Add(1)
|
||||
go func() {
|
||||
defer cancel()
|
||||
as.readLoop(ctx, cmd, stdout, &stderrBuf, append(imageRefs, fileRefs...), preEntries, time.Now())
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildAntigravityArgs(chatID string, isResume bool, mode, fullPrompt string) []string {
|
||||
// Keep "-p <prompt>" at the very end because agy consumes the immediate next arg.
|
||||
args := make([]string, 0, 10)
|
||||
if isResume {
|
||||
args = append(args, "--conversation", chatID)
|
||||
}
|
||||
switch mode {
|
||||
case "yolo":
|
||||
args = append(args, "--dangerously-skip-permissions")
|
||||
case "plan":
|
||||
args = append(args, "--sandbox")
|
||||
}
|
||||
args = append(args, "-p", fullPrompt)
|
||||
return args
|
||||
}
|
||||
|
||||
func usesInteractivePermission(mode string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(mode), "default")
|
||||
}
|
||||
|
||||
func (as *antigravitySession) readLoop(ctx context.Context, cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer, tempFiles []string, preEntries map[string]bool, sendStartedAt time.Time) {
|
||||
defer as.wg.Done()
|
||||
defer func() {
|
||||
for _, f := range tempFiles {
|
||||
_ = os.Remove(f)
|
||||
}
|
||||
|
||||
// Detect conversation ID if this was the first turn of a fresh session
|
||||
if as.CurrentSessionID() == "" {
|
||||
var sid string
|
||||
for attempt := 0; attempt < 15; attempt++ {
|
||||
sid = as.detectNewSessionID(preEntries, sendStartedAt)
|
||||
if sid != "" {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if sid != "" {
|
||||
as.chatID.Store(sid)
|
||||
slog.Debug("antigravitySession: detected session ID", "session_id", sid)
|
||||
// Emit an EventText carrying the session ID back to core
|
||||
select {
|
||||
case as.events <- core.Event{Type: core.EventText, SessionID: sid}:
|
||||
case <-as.ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := cmd.Wait()
|
||||
sid := as.CurrentSessionID()
|
||||
if err != nil {
|
||||
stderrMsg := strings.TrimSpace(stderrBuf.String())
|
||||
if stderrMsg != "" {
|
||||
slog.Error("antigravitySession: process failed", "error", err, "stderr", stderrMsg)
|
||||
select {
|
||||
case as.events <- core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}:
|
||||
case <-as.ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize turn
|
||||
select {
|
||||
case as.events <- core.Event{Type: core.EventResult, SessionID: sid, Done: true}:
|
||||
case <-as.ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = stdout.Close()
|
||||
}()
|
||||
|
||||
reader := bufio.NewReader(stdout)
|
||||
buf := make([]byte, 1024)
|
||||
permWindow := ""
|
||||
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
text := string(buf[:n])
|
||||
permWindow += text
|
||||
if len(permWindow) > 4096 {
|
||||
permWindow = permWindow[len(permWindow)-4096:]
|
||||
}
|
||||
if pending, _ := as.permReqID.Load().(string); pending == "" {
|
||||
if prompt, ok := extractPermissionPrompt(permWindow); ok {
|
||||
requestID := fmt.Sprintf("agy-perm-%d", time.Now().UnixNano())
|
||||
as.permReqID.Store(requestID)
|
||||
select {
|
||||
case as.events <- core.Event{
|
||||
Type: core.EventPermissionRequest,
|
||||
RequestID: requestID,
|
||||
ToolName: "terminal_permission",
|
||||
ToolInput: prompt,
|
||||
ToolInputRaw: map[string]any{"prompt": prompt},
|
||||
}:
|
||||
case <-as.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case as.events <- core.Event{Type: core.EventText, Content: text}:
|
||||
case <-as.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
|
||||
slog.Error("antigravitySession: read error", "error", err)
|
||||
select {
|
||||
case as.events <- core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}:
|
||||
case <-as.ctx.Done():
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (as *antigravitySession) detectNewSessionID(preEntries map[string]bool, sendStartedAt time.Time) string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
slug := antigravityProjectSlug(as.workDir)
|
||||
chatsDir := filepath.Join(homeDir, ".gemini", "tmp", slug, "chats")
|
||||
|
||||
entries, err := os.ReadDir(chatsDir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
sessionID string
|
||||
modTime time.Time
|
||||
diff time.Duration
|
||||
}
|
||||
var candidates []candidate
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
if preEntries[entry.Name()] {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mod := info.ModTime()
|
||||
if !sendStartedAt.IsZero() && mod.Before(sendStartedAt.Add(-2*time.Second)) {
|
||||
continue
|
||||
}
|
||||
|
||||
fpath := filepath.Join(chatsDir, entry.Name())
|
||||
file, err := os.Open(fpath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
if scanner.Scan() {
|
||||
var sf struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
if json.Unmarshal([]byte(scanner.Text()), &sf) == nil && sf.SessionID != "" {
|
||||
diff := time.Duration(0)
|
||||
if !sendStartedAt.IsZero() {
|
||||
if mod.After(sendStartedAt) {
|
||||
diff = mod.Sub(sendStartedAt)
|
||||
} else {
|
||||
diff = sendStartedAt.Sub(mod)
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, candidate{
|
||||
sessionID: sf.SessionID,
|
||||
modTime: mod,
|
||||
diff: diff,
|
||||
})
|
||||
}
|
||||
}
|
||||
_ = file.Close()
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].diff == candidates[j].diff {
|
||||
return candidates[i].modTime.After(candidates[j].modTime)
|
||||
}
|
||||
return candidates[i].diff < candidates[j].diff
|
||||
})
|
||||
return candidates[0].sessionID
|
||||
}
|
||||
|
||||
func extractPermissionPrompt(text string) (string, bool) {
|
||||
loc := permissionPromptPattern.FindStringIndex(text)
|
||||
if loc == nil {
|
||||
return "", false
|
||||
}
|
||||
prompt := strings.TrimSpace(text[loc[0]:loc[1]])
|
||||
if prompt == "" {
|
||||
return "", false
|
||||
}
|
||||
return prompt, true
|
||||
}
|
||||
|
||||
func (as *antigravitySession) RespondPermission(requestID string, result core.PermissionResult) error {
|
||||
if !as.alive.Load() {
|
||||
return fmt.Errorf("session is closed")
|
||||
}
|
||||
if pending, _ := as.permReqID.Load().(string); pending != "" && requestID != "" && requestID != pending {
|
||||
return fmt.Errorf("permission request mismatch: got %q, pending %q", requestID, pending)
|
||||
}
|
||||
as.stdinMu.Lock()
|
||||
defer as.stdinMu.Unlock()
|
||||
if as.stdin == nil {
|
||||
return fmt.Errorf("stdin is not available")
|
||||
}
|
||||
// agy permission prompts accept terminal-style responses.
|
||||
// Keep this conservative until agy exposes a structured permission protocol.
|
||||
reply := "y\n"
|
||||
if strings.EqualFold(result.Behavior, "deny") {
|
||||
reply = "n\n"
|
||||
}
|
||||
_, err := io.WriteString(as.stdin, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write permission response: %w", err)
|
||||
}
|
||||
as.permReqID.Store("")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (as *antigravitySession) Events() <-chan core.Event {
|
||||
return as.events
|
||||
}
|
||||
|
||||
func (as *antigravitySession) CurrentSessionID() string {
|
||||
v, _ := as.chatID.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (as *antigravitySession) Alive() bool {
|
||||
return as.alive.Load()
|
||||
}
|
||||
|
||||
func (as *antigravitySession) Close() error {
|
||||
as.alive.Store(false)
|
||||
as.cancel()
|
||||
as.stdinMu.Lock()
|
||||
if as.stdin != nil {
|
||||
_ = as.stdin.Close()
|
||||
as.stdin = nil
|
||||
}
|
||||
as.stdinMu.Unlock()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
as.wg.Wait()
|
||||
as.closeOnce.Do(func() {
|
||||
close(as.events)
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(8 * time.Second):
|
||||
slog.Warn("antigravitySession: close timed out")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
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 TestNormalizeMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"default", "default"},
|
||||
{"yolo", "yolo"},
|
||||
{"auto", "yolo"},
|
||||
{"force", "yolo"},
|
||||
{"plan", "plan"},
|
||||
{"sandbox", "plan"},
|
||||
{"invalid", "default"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeMode(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeMode(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSession_ContinueSessionTreatedAsFresh(t *testing.T) {
|
||||
s, err := newAntigravitySession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("newAntigravitySession: %v", err)
|
||||
}
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
if got := s.CurrentSessionID(); got != "" {
|
||||
t.Errorf("ContinueSession should be treated as fresh: chatID = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAntigravityArgs_PromptAtEnd(t *testing.T) {
|
||||
args := buildAntigravityArgs("sid-1", true, "plan", "What is 1+1?")
|
||||
if len(args) < 2 {
|
||||
t.Fatalf("args too short: %v", args)
|
||||
}
|
||||
if args[len(args)-2] != "-p" || args[len(args)-1] != "What is 1+1?" {
|
||||
t.Fatalf("expected prompt to be final '-p <prompt>', got: %v", args)
|
||||
}
|
||||
if !contains(args, "--sandbox") {
|
||||
t.Fatalf("expected --sandbox in args, got: %v", args)
|
||||
}
|
||||
if contains(args, "-m") || contains(args, "--model") {
|
||||
t.Fatalf("did not expect model flags in args, got: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsesInteractivePermission(t *testing.T) {
|
||||
if !usesInteractivePermission("default") {
|
||||
t.Fatal("default mode should use interactive permission stdin")
|
||||
}
|
||||
if usesInteractivePermission("yolo") {
|
||||
t.Fatal("yolo mode should not use interactive permission stdin")
|
||||
}
|
||||
if usesInteractivePermission("plan") {
|
||||
t.Fatal("plan mode should not use interactive permission stdin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondPermission_WritesTerminalAnswer(t *testing.T) {
|
||||
s, err := newAntigravitySession(context.Background(), "echo", "/tmp", "", "default", "", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("newAntigravitySession: %v", err)
|
||||
}
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
defer func() { _ = w.Close() }()
|
||||
s.stdin = w
|
||||
|
||||
s.permReqID.Store("req")
|
||||
if err := s.RespondPermission("req", core.PermissionResult{Behavior: "allow"}); err != nil {
|
||||
t.Fatalf("RespondPermission allow: %v", err)
|
||||
}
|
||||
buf := make([]byte, 8)
|
||||
n, err := r.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
t.Fatalf("read allow response: %v", err)
|
||||
}
|
||||
if got := string(buf[:n]); got != "y\n" {
|
||||
t.Fatalf("allow response = %q, want %q", got, "y\n")
|
||||
}
|
||||
|
||||
s.permReqID.Store("req")
|
||||
if err := s.RespondPermission("req", core.PermissionResult{Behavior: "deny"}); err != nil {
|
||||
t.Fatalf("RespondPermission deny: %v", err)
|
||||
}
|
||||
n, err = r.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
t.Fatalf("read deny response: %v", err)
|
||||
}
|
||||
if got := string(buf[:n]); got != "n\n" {
|
||||
t.Fatalf("deny response = %q, want %q", got, "n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPermissionPrompt(t *testing.T) {
|
||||
text := "Tool wants to run command. Allow this action? (y/N)"
|
||||
got, ok := extractPermissionPrompt(text)
|
||||
if !ok {
|
||||
t.Fatalf("expected permission prompt to be detected")
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatalf("detected prompt should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPermissionPrompt_SplitChunksDetectedInWindow(t *testing.T) {
|
||||
part1 := "Tool wants to run command. Allow this"
|
||||
part2 := " action? (y/N)"
|
||||
got, ok := extractPermissionPrompt(part1 + part2)
|
||||
if !ok || got == "" {
|
||||
t.Fatalf("expected split prompt to be detected, got ok=%v prompt=%q", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(xs []string, want string) bool {
|
||||
for _, x := range xs {
|
||||
if strings.TrimSpace(x) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user