初始化仓库
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterAgent("iflow", New)
|
||||
}
|
||||
|
||||
// Agent drives iFlow CLI one turn at a time using interactive `iflow -i`
|
||||
// inside a PTY, then reconstructs streaming events from the transcript JSONL.
|
||||
//
|
||||
// Modes (maps to iFlow CLI flags):
|
||||
// - "default": manual approval mode (--default)
|
||||
// - "auto-edit": auto-edit mode (--autoEdit)
|
||||
// - "plan": read-only planning mode (--plan)
|
||||
// - "yolo": auto-approve all tool calls (--yolo)
|
||||
type Agent struct {
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
cmd string
|
||||
toolTimeoutSec int
|
||||
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 = "iflow"
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath(cmd); err != nil {
|
||||
return nil, fmt.Errorf("iflow: %q CLI not found in PATH, install with: npm i -g @iflow-ai/iflow-cli", cmd)
|
||||
}
|
||||
|
||||
var toolTimeoutSec int
|
||||
switch v := opts["tool_timeout_secs"].(type) {
|
||||
case int64:
|
||||
toolTimeoutSec = int(v)
|
||||
case int:
|
||||
toolTimeoutSec = v
|
||||
case float64:
|
||||
toolTimeoutSec = int(v)
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
cmd: cmd,
|
||||
toolTimeoutSec: toolTimeoutSec,
|
||||
activeIdx: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "auto-edit", "auto_edit", "autoedit", "edit":
|
||||
return "auto-edit"
|
||||
case "plan":
|
||||
return "plan"
|
||||
case "yolo", "force", "auto", "bypasspermissions":
|
||||
return "yolo"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) Name() string { return "iflow" }
|
||||
|
||||
func (a *Agent) SetWorkDir(dir string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.workDir = dir
|
||||
slog.Info("iflow: 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("iflow: 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(_ context.Context) []core.ModelOption {
|
||||
if models := a.configuredModels(); len(models) > 0 {
|
||||
return models
|
||||
}
|
||||
return []core.ModelOption{
|
||||
{Name: "Qwen3-Coder", Desc: "Qwen3 Coder"},
|
||||
{Name: "Kimi-K2.5", Desc: "Kimi K2.5"},
|
||||
{Name: "DeepSeek-v3", Desc: "DeepSeek v3"},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
toolTimeoutSec := a.toolTimeoutSec
|
||||
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 newIFlowSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, toolTimeoutSec)
|
||||
}
|
||||
|
||||
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
a.mu.RLock()
|
||||
workDir := a.workDir
|
||||
a.mu.RUnlock()
|
||||
return listIFlowSessions(workDir)
|
||||
}
|
||||
|
||||
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return fmt.Errorf("session id is required")
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("iflow: cannot determine home dir: %w", err)
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
workDir := a.workDir
|
||||
a.mu.RUnlock()
|
||||
absDir := iflowResolvedWorkDir(workDir)
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(homeDir, ".iflow", "projects", iflowProjectKey(absDir), sessionID+".jsonl"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return os.Remove(p)
|
||||
}
|
||||
}
|
||||
|
||||
matches, _ := filepath.Glob(filepath.Join(homeDir, ".iflow", "projects", "*", sessionID+".jsonl"))
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("session file not found: %s", sessionID)
|
||||
}
|
||||
return os.Remove(matches[0])
|
||||
}
|
||||
|
||||
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("iflow: 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: "Manual approval mode", DescZh: "手动审批模式"},
|
||||
{Key: "auto-edit", Name: "Auto Edit", NameZh: "自动编辑", Desc: "Auto-edit mode", DescZh: "自动编辑模式"},
|
||||
{Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only planning mode", DescZh: "只读规划模式"},
|
||||
{Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
|
||||
}
|
||||
}
|
||||
|
||||
// -- ContextCompressor --
|
||||
|
||||
func (a *Agent) CompressCommand() string { return "/compress" }
|
||||
|
||||
// -- MemoryFileProvider --
|
||||
|
||||
func (a *Agent) ProjectMemoryFile() string {
|
||||
a.mu.RLock()
|
||||
workDir := a.workDir
|
||||
a.mu.RUnlock()
|
||||
absDir, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
absDir = workDir
|
||||
}
|
||||
return filepath.Join(absDir, "IFLOW.md")
|
||||
}
|
||||
|
||||
func (a *Agent) GlobalMemoryFile() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(homeDir, ".iflow", "IFLOW.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("iflow: provider cleared")
|
||||
return true
|
||||
}
|
||||
for i, p := range a.providers {
|
||||
if p.Name == name {
|
||||
a.activeIdx = i
|
||||
slog.Info("iflow: 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,
|
||||
"IFLOW_API_KEY="+p.APIKey,
|
||||
"IFLOW_apiKey="+p.APIKey,
|
||||
)
|
||||
}
|
||||
if p.BaseURL != "" {
|
||||
env = append(env,
|
||||
"IFLOW_BASE_URL="+p.BaseURL,
|
||||
"IFLOW_baseUrl="+p.BaseURL,
|
||||
)
|
||||
}
|
||||
for k, v := range p.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// -- Session listing helpers --
|
||||
|
||||
type iflowTranscriptLine struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Type string `json:"type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
func listIFlowSessions(workDir string) ([]core.AgentSessionInfo, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iflow: cannot determine home dir: %w", err)
|
||||
}
|
||||
|
||||
absDir := iflowResolvedWorkDir(workDir)
|
||||
|
||||
sessionDir := filepath.Join(homeDir, ".iflow", "projects", iflowProjectKey(absDir))
|
||||
entries, err := os.ReadDir(sessionDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("iflow: read sessions dir: %w", err)
|
||||
}
|
||||
|
||||
var sessions []core.AgentSessionInfo
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "session-") || !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(sessionDir, name)
|
||||
sid, summary, msgCount, modifiedAt := parseIFlowSessionFile(path)
|
||||
if sid == "" {
|
||||
sid = strings.TrimSuffix(name, ".jsonl")
|
||||
}
|
||||
if summary == "" {
|
||||
summary = sid
|
||||
if utf8.RuneCountInString(summary) > 12 {
|
||||
summary = string([]rune(summary)[:12]) + "..."
|
||||
}
|
||||
}
|
||||
if modifiedAt.IsZero() {
|
||||
if fi, err := e.Info(); err == nil {
|
||||
modifiedAt = fi.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
sessions = append(sessions, core.AgentSessionInfo{
|
||||
ID: sid,
|
||||
Summary: summary,
|
||||
MessageCount: msgCount,
|
||||
ModifiedAt: modifiedAt,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(sessions, func(i, j int) bool {
|
||||
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
|
||||
})
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func parseIFlowSessionFile(path string) (sid, summary string, msgCount int, modifiedAt time.Time) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", "", 0, time.Time{}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
msgCount++
|
||||
|
||||
var item iflowTranscriptLine
|
||||
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
||||
continue
|
||||
}
|
||||
if sid == "" && item.SessionID != "" {
|
||||
sid = item.SessionID
|
||||
}
|
||||
if !item.Timestamp.IsZero() && item.Timestamp.After(modifiedAt) {
|
||||
modifiedAt = item.Timestamp
|
||||
}
|
||||
if summary == "" && item.Type == "user" {
|
||||
if text := extractIFlowContentText(item.Message.Content); text != "" {
|
||||
summary = firstNonEmptyLine(text)
|
||||
if utf8.RuneCountInString(summary) > 60 {
|
||||
summary = string([]rune(summary)[:60]) + "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sid, summary, msgCount, modifiedAt
|
||||
}
|
||||
|
||||
func extractIFlowContentText(content any) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case []any:
|
||||
for _, it := range v {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t, _ := m["text"].(string); t != "" {
|
||||
return strings.TrimSpace(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmptyLine(s string) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func iflowProjectKey(absDir string) string {
|
||||
if absDir == "" {
|
||||
return ""
|
||||
}
|
||||
key := filepath.Clean(absDir)
|
||||
key = strings.ReplaceAll(key, "\\", "-")
|
||||
key = strings.ReplaceAll(key, "/", "-")
|
||||
key = strings.ReplaceAll(key, ":", "-")
|
||||
return key
|
||||
}
|
||||
|
||||
func iflowResolvedWorkDir(workDir string) string {
|
||||
absDir, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
absDir = workDir
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(absDir); err == nil && resolved != "" {
|
||||
absDir = resolved
|
||||
}
|
||||
return absDir
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func TestIFlowSessionIntegration(t *testing.T) {
|
||||
if os.Getenv("IFLOW_INTEGRATION") == "" {
|
||||
t.Skip("set IFLOW_INTEGRATION=1 to run")
|
||||
}
|
||||
if _, err := exec.LookPath("iflow"); err != nil {
|
||||
t.Skip("iflow CLI not found in PATH")
|
||||
}
|
||||
|
||||
a, err := New(map[string]any{"work_dir": "/tmp"})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
sess, err := a.StartSession(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
if err := sess.Send("Reply exactly READY", nil, nil); err != nil {
|
||||
t.Fatalf("Send #1: %v", err)
|
||||
}
|
||||
res1 := waitForResult(t, sess.Events())
|
||||
if !strings.Contains(strings.ToUpper(res1.Content), "READY") {
|
||||
t.Fatalf("unexpected first result: %q", res1.Content)
|
||||
}
|
||||
sid := sess.CurrentSessionID()
|
||||
if sid == "" {
|
||||
t.Fatal("expected session id after first send")
|
||||
}
|
||||
|
||||
if err := sess.Send("Reply exactly RESUMED", nil, nil); err != nil {
|
||||
t.Fatalf("Send #2: %v", err)
|
||||
}
|
||||
res2 := waitForResult(t, sess.Events())
|
||||
if !strings.Contains(strings.ToUpper(res2.Content), "RESUMED") {
|
||||
t.Fatalf("unexpected second result: %q", res2.Content)
|
||||
}
|
||||
if sid2 := sess.CurrentSessionID(); sid2 != sid {
|
||||
t.Fatalf("session id changed: %q -> %q", sid, sid2)
|
||||
}
|
||||
|
||||
resumed, err := a.StartSession(context.Background(), sid)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession resume: %v", err)
|
||||
}
|
||||
defer resumed.Close()
|
||||
if err := resumed.Send("Reply exactly CONTINUED", nil, nil); err != nil {
|
||||
t.Fatalf("Send resume: %v", err)
|
||||
}
|
||||
res3 := waitForResult(t, resumed.Events())
|
||||
if !strings.Contains(strings.ToUpper(res3.Content), "CONTINUED") {
|
||||
t.Fatalf("unexpected resumed result: %q", res3.Content)
|
||||
}
|
||||
if sid3 := resumed.CurrentSessionID(); sid3 != sid {
|
||||
t.Fatalf("resumed session id mismatch: want %q, got %q", sid, sid3)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForResult(t *testing.T, ch <-chan core.Event) core.Event {
|
||||
t.Helper()
|
||||
timeout := time.After(90 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
t.Fatal("events channel closed")
|
||||
}
|
||||
switch ev.Type {
|
||||
case core.EventError:
|
||||
t.Fatalf("event error: %v", ev.Error)
|
||||
case core.EventResult:
|
||||
fmt.Printf("[RESULT] sid=%s content=%s\n", ev.SessionID, ev.Content)
|
||||
return ev
|
||||
}
|
||||
case <-timeout:
|
||||
t.Fatal("timeout waiting for result")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func TestNormalizeMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", "default"},
|
||||
{"default", "default"},
|
||||
{"AUTO-EDIT", "auto-edit"},
|
||||
{"auto_edit", "auto-edit"},
|
||||
{"edit", "auto-edit"},
|
||||
{"plan", "plan"},
|
||||
{"yolo", "yolo"},
|
||||
{"force", "yolo"},
|
||||
{"unknown", "default"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := normalizeMode(tc.in); got != tc.want {
|
||||
t.Fatalf("normalizeMode(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderEnvLocked(t *testing.T) {
|
||||
a := &Agent{
|
||||
providers: []core.ProviderConfig{
|
||||
{
|
||||
Name: "custom",
|
||||
APIKey: "k1",
|
||||
BaseURL: "https://example.com/v1",
|
||||
Env: map[string]string{
|
||||
"FOO": "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
activeIdx: 0,
|
||||
}
|
||||
|
||||
got := a.providerEnvLocked()
|
||||
wantSubset := []string{
|
||||
"IFLOW_API_KEY=k1",
|
||||
"IFLOW_apiKey=k1",
|
||||
"IFLOW_BASE_URL=https://example.com/v1",
|
||||
"IFLOW_baseUrl=https://example.com/v1",
|
||||
"FOO=bar",
|
||||
}
|
||||
|
||||
for _, item := range wantSubset {
|
||||
if !contains(got, item) {
|
||||
t.Fatalf("providerEnvLocked() missing %q; got=%v", item, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIFlowProjectKey(t *testing.T) {
|
||||
path := "/Users/test/project"
|
||||
got := iflowProjectKey(path)
|
||||
if got != "-Users-test-project" {
|
||||
t.Fatalf("iflowProjectKey(%q) = %q", path, got)
|
||||
}
|
||||
|
||||
if got := iflowProjectKey(""); got != "" {
|
||||
t.Fatalf("iflowProjectKey(\"\") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIFlowResolvedWorkDir(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
realDir := filepath.Join(base, "real")
|
||||
linkDir := filepath.Join(base, "link")
|
||||
if err := os.Mkdir(realDir, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
if err := os.Symlink(realDir, linkDir); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
|
||||
want, err := filepath.EvalSymlinks(realDir)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks(realDir): %v", err)
|
||||
}
|
||||
|
||||
if got := iflowResolvedWorkDir(linkDir); got != want {
|
||||
t.Fatalf("iflowResolvedWorkDir(%q) = %q, want %q", linkDir, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIFlowContentText(t *testing.T) {
|
||||
if got := extractIFlowContentText("hello"); got != "hello" {
|
||||
t.Fatalf("extractIFlowContentText string = %q", got)
|
||||
}
|
||||
|
||||
arr := []any{map[string]any{"type": "text", "text": "from-array"}}
|
||||
if got := extractIFlowContentText(arr); got != "from-array" {
|
||||
t.Fatalf("extractIFlowContentText array = %q", got)
|
||||
}
|
||||
|
||||
if got := extractIFlowContentText(123); got != "" {
|
||||
t.Fatalf("extractIFlowContentText unexpected = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionModesKeys(t *testing.T) {
|
||||
a := &Agent{}
|
||||
modes := a.PermissionModes()
|
||||
got := make([]string, 0, len(modes))
|
||||
for _, m := range modes {
|
||||
got = append(got, m.Key)
|
||||
}
|
||||
want := []string{"default", "auto-edit", "plan", "yolo"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("PermissionModes keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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 contains(list []string, target string) bool {
|
||||
for _, item := range list {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestIFlowAgent_WorkDirRaceFreeReaders pins the bug where ListSessions,
|
||||
// DeleteSession, and ProjectMemoryFile read a.workDir without holding
|
||||
// a.mu, while SetWorkDir mutates it under the lock. Running this with
|
||||
// -race flags the data race; with the fix the race detector stays quiet.
|
||||
func TestIFlowAgent_WorkDirRaceFreeReaders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := &Agent{workDir: dir}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if i%2 == 0 {
|
||||
a.SetWorkDir(filepath.Join(dir, "a"))
|
||||
} else {
|
||||
a.SetWorkDir(filepath.Join(dir, "b"))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = a.ListSessions(context.Background())
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = a.DeleteSession(context.Background(), "no-such-session")
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = a.ProjectMemoryFile()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionIDRe = regexp.MustCompile(`"session-id"\s*:\s*"([^"]+)"`)
|
||||
ansiCSIRe = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
|
||||
ansiOSCRe = regexp.MustCompile(`\x1b\][^\a]*(?:\a|\x1b\\)`)
|
||||
)
|
||||
|
||||
const (
|
||||
iflowTurnIdle = 900 * time.Millisecond
|
||||
iflowTranscriptPoll = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
var iflowPendingToolTimeout = 180 * time.Second
|
||||
var iflowPendingToolTimeoutDefaultMode = 6 * time.Second
|
||||
|
||||
// iflowSession manages multi-turn conversations with iFlow CLI.
|
||||
// Each Send() launches a fresh interactive `iflow -i` process inside a PTY,
|
||||
// then tails the transcript JSONL to recover structured assistant/tool events.
|
||||
type iflowSession struct {
|
||||
cmd string
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
toolTimeoutSec int
|
||||
extraEnv []string
|
||||
events chan core.Event
|
||||
sessionID atomic.Value // stores string
|
||||
sentOnce atomic.Bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
alive atomic.Bool
|
||||
turnActive atomic.Bool
|
||||
}
|
||||
|
||||
type iflowTurn struct {
|
||||
cancel context.CancelFunc
|
||||
startedAt time.Time
|
||||
mode string
|
||||
pendingTimeout time.Duration
|
||||
sessionDir string
|
||||
transcriptPath string
|
||||
offset int64
|
||||
partial string
|
||||
processDone chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
pendingToolIDs map[string]struct{}
|
||||
pendingTools map[string]iflowToolUse
|
||||
seenToolIDs map[string]struct{}
|
||||
doneToolIDs map[string]struct{}
|
||||
pendingTimer *time.Timer
|
||||
resultTimer *time.Timer
|
||||
resultText string
|
||||
toolFallback []string
|
||||
awaitingTool bool
|
||||
resultReady bool
|
||||
resultSent bool
|
||||
}
|
||||
|
||||
type iflowToolUse struct {
|
||||
ID string
|
||||
Name string
|
||||
Input any
|
||||
}
|
||||
|
||||
type iflowToolResult struct {
|
||||
ID string
|
||||
Output string
|
||||
}
|
||||
|
||||
func newIFlowSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, toolTimeoutSec int) (*iflowSession, error) {
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
s := &iflowSession{
|
||||
cmd: cmd,
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
toolTimeoutSec: toolTimeoutSec,
|
||||
extraEnv: extraEnv,
|
||||
events: make(chan core.Event, 64),
|
||||
ctx: sessionCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
s.alive.Store(true)
|
||||
|
||||
if resumeID != "" && resumeID != core.ContinueSession {
|
||||
s.sessionID.Store(resumeID)
|
||||
s.sentOnce.Store(true)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *iflowSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
if len(images) > 0 {
|
||||
slog.Warn("iflowSession: images are not supported, ignoring")
|
||||
}
|
||||
if len(files) > 0 {
|
||||
filePaths := core.SaveFilesToDisk(s.workDir, files)
|
||||
prompt = core.AppendFileRefs(prompt, filePaths)
|
||||
}
|
||||
if !s.alive.Load() {
|
||||
return fmt.Errorf("session is closed")
|
||||
}
|
||||
if !s.turnActive.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("iflow session is busy")
|
||||
}
|
||||
|
||||
turnCtx, turnCancel := context.WithCancel(s.ctx)
|
||||
turn := &iflowTurn{
|
||||
cancel: turnCancel,
|
||||
startedAt: time.Now(),
|
||||
mode: s.mode,
|
||||
pendingTimeout: s.pendingToolTimeout(),
|
||||
processDone: make(chan struct{}),
|
||||
pendingToolIDs: make(map[string]struct{}),
|
||||
pendingTools: make(map[string]iflowToolUse),
|
||||
seenToolIDs: make(map[string]struct{}),
|
||||
doneToolIDs: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if !s.turnActive.Load() {
|
||||
turnCancel()
|
||||
}
|
||||
}()
|
||||
|
||||
sessionDir, err := iflowSessionDir(s.workDir)
|
||||
if err != nil {
|
||||
s.turnActive.Store(false)
|
||||
return fmt.Errorf("iflowSession: resolve session dir: %w", err)
|
||||
}
|
||||
turn.sessionDir = sessionDir
|
||||
|
||||
args := make([]string, 0, 16)
|
||||
if s.model != "" {
|
||||
args = append(args, "-m", s.model)
|
||||
}
|
||||
|
||||
switch s.mode {
|
||||
case "yolo":
|
||||
args = append(args, "--yolo")
|
||||
case "plan":
|
||||
args = append(args, "--plan")
|
||||
case "auto-edit":
|
||||
args = append(args, "--autoEdit")
|
||||
default:
|
||||
args = append(args, "--default")
|
||||
}
|
||||
|
||||
sid := s.CurrentSessionID()
|
||||
if sid != "" {
|
||||
args = append(args, "-r", sid)
|
||||
turn.transcriptPath = filepath.Join(sessionDir, sid+".jsonl")
|
||||
turn.offset = fileSize(turn.transcriptPath)
|
||||
} else if s.sentOnce.Load() {
|
||||
args = append(args, "-c")
|
||||
}
|
||||
|
||||
args = append(args, "-i", prompt)
|
||||
slog.Debug("iflowSession: launching interactive turn", "resume", sid != "", "args", core.RedactArgs(args))
|
||||
|
||||
cmd := exec.CommandContext(turnCtx, s.cmd, args...)
|
||||
cmd.Dir = s.workDir
|
||||
env := os.Environ()
|
||||
if len(s.extraEnv) > 0 {
|
||||
env = core.MergeEnv(env, s.extraEnv)
|
||||
}
|
||||
cmd.Env = env
|
||||
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
s.turnActive.Store(false)
|
||||
return fmt.Errorf("iflowSession: start pty: %w", err)
|
||||
}
|
||||
|
||||
s.sentOnce.Store(true)
|
||||
s.wg.Add(1)
|
||||
go s.readLoop(turn, cmd, ptmx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *iflowSession) readLoop(turn *iflowTurn, cmd *exec.Cmd, ptmx *os.File) {
|
||||
defer s.wg.Done()
|
||||
defer s.turnActive.Store(false)
|
||||
defer turn.cancel()
|
||||
defer ptmx.Close()
|
||||
|
||||
var termBuf bytes.Buffer
|
||||
drainDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(&termBuf, ptmx)
|
||||
close(drainDone)
|
||||
}()
|
||||
|
||||
watchDone := make(chan struct{})
|
||||
go func() {
|
||||
s.watchTranscript(turn)
|
||||
close(watchDone)
|
||||
}()
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
close(turn.processDone)
|
||||
_ = ptmx.Close()
|
||||
<-drainDone
|
||||
<-watchDone
|
||||
turn.stopResultTimer()
|
||||
|
||||
// Clear busy state before emitting events so callers can Send() immediately
|
||||
// after receiving the event. The defer above serves as a safety net.
|
||||
s.turnActive.Store(false)
|
||||
|
||||
termText := strings.TrimSpace(stripANSI(termBuf.String()))
|
||||
if turn.readyForResult() {
|
||||
turn.markResultSent()
|
||||
s.emitEvent(core.Event{
|
||||
Type: core.EventResult,
|
||||
Content: turn.finalContent(),
|
||||
SessionID: s.CurrentSessionID(),
|
||||
Done: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if turn.resultWasSent() {
|
||||
return
|
||||
}
|
||||
|
||||
if waitErr != nil {
|
||||
if s.ctx.Err() != nil || errors.Is(waitErr, context.Canceled) {
|
||||
return
|
||||
}
|
||||
s.emitEvent(core.Event{Type: core.EventError, Error: summarizeIFlowError(termText, waitErr)})
|
||||
return
|
||||
}
|
||||
|
||||
if termText != "" && isIFlowAPIFailure(termText) {
|
||||
s.emitEvent(core.Event{Type: core.EventError, Error: summarizeIFlowError(termText, nil)})
|
||||
return
|
||||
}
|
||||
|
||||
s.emitEvent(core.Event{
|
||||
Type: core.EventResult,
|
||||
Content: turn.finalContent(),
|
||||
SessionID: s.CurrentSessionID(),
|
||||
Done: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *iflowSession) watchTranscript(turn *iflowTurn) {
|
||||
ticker := time.NewTicker(iflowTranscriptPoll)
|
||||
defer ticker.Stop()
|
||||
|
||||
processDone := false
|
||||
|
||||
for {
|
||||
if turn.transcriptPath == "" {
|
||||
if path := findIFlowTranscriptPath(turn.sessionDir, turn.startedAt); path != "" {
|
||||
turn.transcriptPath = path
|
||||
turn.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
if turn.transcriptPath != "" {
|
||||
if err := s.consumeTranscript(turn); err != nil {
|
||||
slog.Debug("iflowSession: transcript poll failed", "path", turn.transcriptPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if turn.resultWasSent() || processDone {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-turn.processDone:
|
||||
processDone = true
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *iflowSession) pendingToolTimeout() time.Duration {
|
||||
if s.toolTimeoutSec > 0 {
|
||||
return time.Duration(s.toolTimeoutSec) * time.Second
|
||||
}
|
||||
if strings.EqualFold(s.mode, "default") {
|
||||
return iflowPendingToolTimeoutDefaultMode
|
||||
}
|
||||
return iflowPendingToolTimeout
|
||||
}
|
||||
|
||||
func (s *iflowSession) consumeTranscript(turn *iflowTurn) error {
|
||||
f, err := os.Open(turn.transcriptPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi.Size() < turn.offset {
|
||||
turn.offset = fi.Size()
|
||||
turn.partial = ""
|
||||
}
|
||||
if _, err := f.Seek(turn.offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
chunk, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(chunk) == 0 {
|
||||
return nil
|
||||
}
|
||||
turn.offset += int64(len(chunk))
|
||||
|
||||
data := turn.partial + string(chunk)
|
||||
lines := strings.Split(data, "\n")
|
||||
if !strings.HasSuffix(data, "\n") {
|
||||
turn.partial = lines[len(lines)-1]
|
||||
lines = lines[:len(lines)-1]
|
||||
} else {
|
||||
turn.partial = ""
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
s.handleTranscriptLine(turn, line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *iflowSession) handleTranscriptLine(turn *iflowTurn, line string) {
|
||||
var item iflowTranscriptLine
|
||||
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
||||
slog.Debug("iflowSession: invalid transcript line", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if item.SessionID != "" {
|
||||
s.sessionID.Store(item.SessionID)
|
||||
}
|
||||
|
||||
switch item.Type {
|
||||
case "assistant":
|
||||
texts, toolUses := extractIFlowAssistantEvents(item.Message.Content)
|
||||
if len(toolUses) > 0 {
|
||||
newTools := turn.addPendingTools(toolUses)
|
||||
for _, tool := range newTools {
|
||||
s.emitEvent(core.Event{
|
||||
Type: core.EventToolUse,
|
||||
ToolName: tool.Name,
|
||||
ToolInput: summarizeIFlowToolInput(tool.Input),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, text := range texts {
|
||||
turn.appendText(text)
|
||||
s.emitEvent(core.Event{Type: core.EventText, Content: text, SessionID: s.CurrentSessionID()})
|
||||
}
|
||||
if len(texts) > 0 && !turn.hasPendingTools() {
|
||||
turn.scheduleResult(s)
|
||||
}
|
||||
|
||||
case "user":
|
||||
toolResults := extractIFlowToolResults(item.Message.Content)
|
||||
_ = turn.completeTools(toolResults)
|
||||
}
|
||||
}
|
||||
|
||||
func iflowSessionDir(workDir string) (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(homeDir, ".iflow", "projects", iflowProjectKey(iflowResolvedWorkDir(workDir))), nil
|
||||
}
|
||||
|
||||
func findIFlowTranscriptPath(sessionDir string, startedAt time.Time) string {
|
||||
matches, err := filepath.Glob(filepath.Join(sessionDir, "session-*.jsonl"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
cutoff := startedAt.Add(-2 * time.Second)
|
||||
var best string
|
||||
var bestMod time.Time
|
||||
for _, path := range matches {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil || fi.IsDir() {
|
||||
continue
|
||||
}
|
||||
if fi.ModTime().Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
if best == "" || fi.ModTime().After(bestMod) {
|
||||
best = path
|
||||
bestMod = fi.ModTime()
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func fileSize(path string) int64 {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return fi.Size()
|
||||
}
|
||||
|
||||
func extractIFlowAssistantEvents(content any) ([]string, []iflowToolUse) {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
text := strings.TrimSpace(v)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []string{text}, nil
|
||||
|
||||
case []any:
|
||||
var texts []string
|
||||
var tools []iflowToolUse
|
||||
for _, raw := range v {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch itemType, _ := item["type"].(string); itemType {
|
||||
case "text":
|
||||
if text, _ := item["text"].(string); strings.TrimSpace(text) != "" {
|
||||
texts = append(texts, strings.TrimSpace(text))
|
||||
}
|
||||
case "tool_use":
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
toolID, _ := item["id"].(string)
|
||||
tools = append(tools, iflowToolUse{
|
||||
ID: toolID,
|
||||
Name: name,
|
||||
Input: item["input"],
|
||||
})
|
||||
}
|
||||
}
|
||||
return texts, tools
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func extractIFlowToolResults(content any) []iflowToolResult {
|
||||
items, ok := content.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []iflowToolResult
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if itemType, _ := item["type"].(string); itemType != "tool_result" {
|
||||
continue
|
||||
}
|
||||
if toolID, _ := item["tool_use_id"].(string); toolID != "" {
|
||||
results = append(results, iflowToolResult{
|
||||
ID: toolID,
|
||||
Output: summarizeIFlowToolResult(item["content"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func summarizeIFlowToolInput(input any) string {
|
||||
m, ok := input.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
"absolute_path",
|
||||
"path",
|
||||
"file_path",
|
||||
"command",
|
||||
"query",
|
||||
"pattern",
|
||||
"prompt",
|
||||
"url",
|
||||
} {
|
||||
if v, _ := m[key].(string); v != "" {
|
||||
return truncateRunes(v, 300)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return truncateRunes(string(b), 300)
|
||||
}
|
||||
|
||||
func summarizeIFlowToolResult(content any) string {
|
||||
m, ok := content.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, path := range [][]string{
|
||||
{"functionResponse", "response", "output"},
|
||||
{"responseParts", "functionResponse", "response", "output"},
|
||||
} {
|
||||
if v := nestedString(m, path...); v != "" {
|
||||
return truncateRunes(strings.TrimSpace(v), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if v := nestedString(m, "resultDisplay"); v != "" {
|
||||
return truncateRunes(strings.TrimSpace(v), 2000)
|
||||
}
|
||||
if v := nestedString(m, "output"); v != "" {
|
||||
return truncateRunes(strings.TrimSpace(v), 2000)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return truncateRunes(string(b), 2000)
|
||||
}
|
||||
|
||||
func nestedString(v any, path ...string) string {
|
||||
cur := v
|
||||
for _, key := range path {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur, ok = m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
s, _ := cur.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func truncateRunes(s string, maxRunes int) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:maxRunes]) + "..."
|
||||
}
|
||||
|
||||
func (t *iflowTurn) addPendingTools(tools []iflowToolUse) []iflowToolUse {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
var added []iflowToolUse
|
||||
for _, tool := range tools {
|
||||
if tool.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := t.doneToolIDs[tool.ID]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := t.seenToolIDs[tool.ID]; ok {
|
||||
continue
|
||||
}
|
||||
t.seenToolIDs[tool.ID] = struct{}{}
|
||||
t.pendingToolIDs[tool.ID] = struct{}{}
|
||||
t.pendingTools[tool.ID] = tool
|
||||
added = append(added, tool)
|
||||
}
|
||||
if len(added) > 0 {
|
||||
timeout := t.pendingTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = iflowPendingToolTimeout
|
||||
}
|
||||
if t.pendingTimer != nil {
|
||||
t.pendingTimer.Stop()
|
||||
t.pendingTimer = nil
|
||||
}
|
||||
t.pendingTimer = time.AfterFunc(timeout, func() {
|
||||
t.mu.Lock()
|
||||
if t.resultSent || len(t.pendingToolIDs) == 0 {
|
||||
t.pendingTimer = nil
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, tool := range t.pendingTools {
|
||||
if tool.Name != "" {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
}
|
||||
msg := "Tool call timed out before completion."
|
||||
if len(names) > 0 {
|
||||
msg += " Pending: " + strings.Join(names, ", ") + "."
|
||||
}
|
||||
if strings.EqualFold(t.mode, "default") {
|
||||
msg += " Default mode requires interactive approval; use /mode yolo or /mode auto-edit for tool calls."
|
||||
} else {
|
||||
msg += " The tool appears stalled."
|
||||
}
|
||||
|
||||
if t.resultText == "" {
|
||||
t.resultText = msg
|
||||
} else {
|
||||
t.resultText = t.resultText + "\n\n" + msg
|
||||
}
|
||||
t.awaitingTool = false
|
||||
t.resultReady = true
|
||||
t.pendingToolIDs = make(map[string]struct{})
|
||||
t.pendingTools = make(map[string]iflowToolUse)
|
||||
t.pendingTimer = nil
|
||||
t.mu.Unlock()
|
||||
|
||||
t.cancel()
|
||||
})
|
||||
|
||||
if t.resultTimer != nil {
|
||||
t.resultTimer.Stop()
|
||||
t.resultTimer = nil
|
||||
}
|
||||
t.awaitingTool = false
|
||||
t.toolFallback = nil
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
func (t *iflowTurn) appendText(text string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.awaitingTool = false
|
||||
t.resultText += text
|
||||
}
|
||||
|
||||
func (t *iflowTurn) completeTools(results []iflowToolResult) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
changed := false
|
||||
for _, result := range results {
|
||||
if _, ok := t.doneToolIDs[result.ID]; ok {
|
||||
continue
|
||||
}
|
||||
delete(t.pendingToolIDs, result.ID)
|
||||
delete(t.pendingTools, result.ID)
|
||||
t.doneToolIDs[result.ID] = struct{}{}
|
||||
if result.Output != "" {
|
||||
t.toolFallback = append(t.toolFallback, result.Output)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if len(t.pendingToolIDs) == 0 && t.pendingTimer != nil {
|
||||
t.pendingTimer.Stop()
|
||||
t.pendingTimer = nil
|
||||
} else if changed && len(t.pendingToolIDs) > 0 && t.pendingTimer != nil {
|
||||
t.pendingTimer.Reset(t.pendingTimeout)
|
||||
}
|
||||
if len(results) == 0 || len(t.pendingToolIDs) > 0 {
|
||||
return false
|
||||
}
|
||||
t.awaitingTool = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *iflowTurn) hasPendingTools() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.pendingToolIDs) > 0
|
||||
}
|
||||
|
||||
func (t *iflowTurn) scheduleResult(s *iflowSession) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.resultSent || len(t.pendingToolIDs) > 0 {
|
||||
return
|
||||
}
|
||||
if t.pendingTimer != nil {
|
||||
t.pendingTimer.Stop()
|
||||
t.pendingTimer = nil
|
||||
}
|
||||
if t.resultTimer != nil {
|
||||
t.resultTimer.Stop()
|
||||
t.resultTimer = nil
|
||||
}
|
||||
|
||||
t.resultTimer = time.AfterFunc(iflowTurnIdle, func() {
|
||||
t.mu.Lock()
|
||||
if t.resultSent || len(t.pendingToolIDs) > 0 {
|
||||
t.resultTimer = nil
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t.resultReady = true
|
||||
t.resultTimer = nil
|
||||
t.mu.Unlock()
|
||||
|
||||
t.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
func (t *iflowTurn) stopResultTimer() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.resultTimer != nil {
|
||||
t.resultTimer.Stop()
|
||||
t.resultTimer = nil
|
||||
}
|
||||
if t.pendingTimer != nil {
|
||||
t.pendingTimer.Stop()
|
||||
t.pendingTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *iflowTurn) resultWasSent() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.resultSent
|
||||
}
|
||||
|
||||
func (t *iflowTurn) readyForResult() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.resultReady
|
||||
}
|
||||
|
||||
func (t *iflowTurn) markResultSent() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.resultSent = true
|
||||
}
|
||||
|
||||
func (t *iflowTurn) finalContent() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if !t.awaitingTool || len(t.toolFallback) == 0 {
|
||||
return t.resultText
|
||||
}
|
||||
|
||||
fallback := strings.Join(t.toolFallback, "\n\n")
|
||||
if t.resultText == "" {
|
||||
return fallback
|
||||
}
|
||||
if strings.Contains(t.resultText, fallback) {
|
||||
return t.resultText
|
||||
}
|
||||
return t.resultText + "\n\n" + fallback
|
||||
}
|
||||
|
||||
func (s *iflowSession) emitEvent(evt core.Event) {
|
||||
select {
|
||||
case s.events <- evt:
|
||||
case <-s.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func readExecutionInfoSessionID(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload struct {
|
||||
SessionID string `json:"session-id"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(payload.SessionID), nil
|
||||
}
|
||||
|
||||
func extractSessionIDFromExecutionInfo(stderrText string) string {
|
||||
m := sessionIDRe.FindStringSubmatch(stderrText)
|
||||
if len(m) == 2 {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stripANSI(s string) string {
|
||||
s = ansiOSCRe.ReplaceAllString(s, "")
|
||||
s = ansiCSIRe.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
return s
|
||||
}
|
||||
|
||||
func isIFlowAPIFailure(stderrText string) bool {
|
||||
if stderrText == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(stderrText)
|
||||
if strings.Contains(lower, "error when talking to iflow api") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "generate data error") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "retrying with backoff") && strings.Contains(lower, "fetch failed") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func summarizeIFlowError(stderrText string, waitErr error) error {
|
||||
if stderrText != "" {
|
||||
for _, line := range strings.Split(stderrText, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "<Execution Info>") || strings.HasPrefix(line, "</Execution Info>") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "{") || strings.HasPrefix(line, "}") || strings.HasPrefix(line, "\"") {
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(line) > 300 {
|
||||
line = string([]rune(line)[:300]) + "..."
|
||||
}
|
||||
return fmt.Errorf("%s", line)
|
||||
}
|
||||
}
|
||||
if waitErr != nil {
|
||||
return fmt.Errorf("iflow process failed: %w", waitErr)
|
||||
}
|
||||
return fmt.Errorf("iflow API request failed")
|
||||
}
|
||||
|
||||
func (s *iflowSession) RespondPermission(_ string, _ core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *iflowSession) Events() <-chan core.Event {
|
||||
return s.events
|
||||
}
|
||||
|
||||
func (s *iflowSession) CurrentSessionID() string {
|
||||
v, _ := s.sessionID.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *iflowSession) Alive() bool {
|
||||
return s.alive.Load()
|
||||
}
|
||||
|
||||
func (s *iflowSession) Close() error {
|
||||
s.alive.Store(false)
|
||||
s.cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
close(s.events)
|
||||
case <-time.After(8 * time.Second):
|
||||
slog.Warn("iflowSession: close timed out, abandoning wg.Wait")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func TestReadExecutionInfoSessionID(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "iflow-exec-info-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
if _, err := f.WriteString(`{"session-id":"session-123"}`); err != nil {
|
||||
t.Fatalf("WriteString: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
sid, err := readExecutionInfoSessionID(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("readExecutionInfoSessionID: %v", err)
|
||||
}
|
||||
if sid != "session-123" {
|
||||
t.Fatalf("session id = %q, want session-123", sid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSessionIDFromExecutionInfo(t *testing.T) {
|
||||
stderr := `<Execution Info>
|
||||
{
|
||||
"session-id": "session-abc",
|
||||
"conversation-id": "cid"
|
||||
}
|
||||
</Execution Info>`
|
||||
if got := extractSessionIDFromExecutionInfo(stderr); got != "session-abc" {
|
||||
t.Fatalf("extractSessionIDFromExecutionInfo = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIFlowAPIFailure(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"Error when talking to iFlow API", true},
|
||||
{"Attempt 1 failed. Retrying with backoff... Error: generate data error: fetch failed", true},
|
||||
{"normal log only", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := isIFlowAPIFailure(tc.in); got != tc.want {
|
||||
t.Fatalf("isIFlowAPIFailure(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeIFlowError(t *testing.T) {
|
||||
err := summarizeIFlowError("Error when talking to iFlow API\n\n<Execution Info>", nil)
|
||||
if err == nil || err.Error() != "Error when talking to iFlow API" {
|
||||
t.Fatalf("unexpected error summary: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIFlowAssistantEvents(t *testing.T) {
|
||||
content := []any{
|
||||
map[string]any{"type": "text", "text": "checking"},
|
||||
map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": "read_file:1",
|
||||
"name": "read_file",
|
||||
"input": map[string]any{"absolute_path": "/tmp/demo.txt"},
|
||||
},
|
||||
}
|
||||
|
||||
texts, tools := extractIFlowAssistantEvents(content)
|
||||
if len(texts) != 1 || texts[0] != "checking" {
|
||||
t.Fatalf("texts = %#v", texts)
|
||||
}
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("tools = %#v", tools)
|
||||
}
|
||||
if tools[0].ID != "read_file:1" || tools[0].Name != "read_file" {
|
||||
t.Fatalf("tool = %#v", tools[0])
|
||||
}
|
||||
if got := summarizeIFlowToolInput(tools[0].Input); got != "/tmp/demo.txt" {
|
||||
t.Fatalf("summarizeIFlowToolInput = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIFlowToolResults(t *testing.T) {
|
||||
content := []any{
|
||||
map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "read_file:1",
|
||||
"content": map[string]any{
|
||||
"functionResponse": map[string]any{
|
||||
"response": map[string]any{
|
||||
"output": "alpha",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "glob:2",
|
||||
"content": map[string]any{
|
||||
"responseParts": map[string]any{
|
||||
"functionResponse": map[string]any{
|
||||
"response": map[string]any{
|
||||
"output": "beta",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := extractIFlowToolResults(content)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if results[0].ID != "read_file:1" || results[0].Output != "alpha" {
|
||||
t.Fatalf("result[0] = %#v", results[0])
|
||||
}
|
||||
if results[1].ID != "glob:2" || results[1].Output != "beta" {
|
||||
t.Fatalf("result[1] = %#v", results[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeIFlowToolResultFallback(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"resultDisplay": "Search results returned.",
|
||||
}
|
||||
if got := summarizeIFlowToolResult(content); got != "Search results returned." {
|
||||
t.Fatalf("summarizeIFlowToolResult = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripANSI(t *testing.T) {
|
||||
in := "\x1b]2;iFlow\x07\x1b[2K\rHello\x1b[?25l"
|
||||
if got := stripANSI(in); got != "Hello" {
|
||||
t.Fatalf("stripANSI = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindIFlowTranscriptPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "session-old.jsonl")
|
||||
newPath := filepath.Join(dir, "session-new.jsonl")
|
||||
if err := os.WriteFile(oldPath, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile old: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(newPath, []byte("new\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile new: %v", err)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
oldTime := startedAt.Add(-5 * time.Second)
|
||||
newTime := startedAt.Add(1 * time.Second)
|
||||
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("Chtimes old: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(newPath, newTime, newTime); err != nil {
|
||||
t.Fatalf("Chtimes new: %v", err)
|
||||
}
|
||||
|
||||
if got := findIFlowTranscriptPath(dir, startedAt); got != newPath {
|
||||
t.Fatalf("findIFlowTranscriptPath = %q, want %q", got, newPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIFlowTurnFinalContentFallsBackToToolResult(t *testing.T) {
|
||||
turn := &iflowTurn{
|
||||
resultText: "我来查一下。",
|
||||
toolFallback: []string{"天气结果"},
|
||||
awaitingTool: true,
|
||||
}
|
||||
|
||||
if got := turn.finalContent(); got != "我来查一下。\n\n天气结果" {
|
||||
t.Fatalf("finalContent = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIFlowTurnIgnoresDuplicateToolUseAfterToolResult(t *testing.T) {
|
||||
turn := &iflowTurn{
|
||||
pendingToolIDs: make(map[string]struct{}),
|
||||
pendingTools: make(map[string]iflowToolUse),
|
||||
seenToolIDs: make(map[string]struct{}),
|
||||
doneToolIDs: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
added := turn.addPendingTools([]iflowToolUse{{ID: "web_search:0", Name: "web_search"}})
|
||||
if len(added) != 1 || !turn.hasPendingTools() {
|
||||
t.Fatalf("first add = %#v pending=%v", added, turn.hasPendingTools())
|
||||
}
|
||||
|
||||
if !turn.completeTools([]iflowToolResult{{ID: "web_search:0", Output: "search result"}}) {
|
||||
t.Fatal("expected tool completion to finish pending set")
|
||||
}
|
||||
if turn.hasPendingTools() {
|
||||
t.Fatal("expected no pending tools after completion")
|
||||
}
|
||||
if !turn.awaitingTool {
|
||||
t.Fatal("expected awaitingTool after tool completion")
|
||||
}
|
||||
|
||||
added = turn.addPendingTools([]iflowToolUse{{ID: "web_search:0", Name: "web_search"}})
|
||||
if len(added) != 0 {
|
||||
t.Fatalf("duplicate add = %#v", added)
|
||||
}
|
||||
if turn.hasPendingTools() {
|
||||
t.Fatal("duplicate tool_use should not recreate pending tool state")
|
||||
}
|
||||
if !turn.awaitingTool {
|
||||
t.Fatal("duplicate tool_use should not clear awaitingTool")
|
||||
}
|
||||
|
||||
turn.appendText("北京今天多云")
|
||||
if turn.awaitingTool {
|
||||
t.Fatal("assistant text should clear awaitingTool")
|
||||
}
|
||||
if got := turn.finalContent(); got != "北京今天多云" {
|
||||
t.Fatalf("finalContent = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIFlowTurnScheduleResultReplacesFallbackTimer(t *testing.T) {
|
||||
turn := &iflowTurn{
|
||||
cancel: func() {},
|
||||
pendingToolIDs: make(map[string]struct{}),
|
||||
pendingTools: make(map[string]iflowToolUse),
|
||||
awaitingTool: false,
|
||||
}
|
||||
turn.resultTimer = time.AfterFunc(time.Hour, func() {})
|
||||
defer turn.stopResultTimer()
|
||||
|
||||
turn.scheduleResult(nil)
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if turn.readyForResult() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("result timer did not fire; callback was not replaced")
|
||||
}
|
||||
|
||||
func TestIFlowTurnPendingToolTimeoutReleasesTurn(t *testing.T) {
|
||||
oldTimeout := iflowPendingToolTimeout
|
||||
iflowPendingToolTimeout = 50 * time.Millisecond
|
||||
defer func() { iflowPendingToolTimeout = oldTimeout }()
|
||||
|
||||
turn := &iflowTurn{
|
||||
cancel: func() {},
|
||||
pendingTimeout: iflowPendingToolTimeout,
|
||||
pendingToolIDs: make(map[string]struct{}),
|
||||
pendingTools: make(map[string]iflowToolUse),
|
||||
seenToolIDs: make(map[string]struct{}),
|
||||
doneToolIDs: make(map[string]struct{}),
|
||||
}
|
||||
defer turn.stopResultTimer()
|
||||
|
||||
added := turn.addPendingTools([]iflowToolUse{{ID: "run_shell_command:1", Name: "run_shell_command"}})
|
||||
if len(added) != 1 {
|
||||
t.Fatalf("added = %#v", added)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if turn.readyForResult() {
|
||||
content := turn.finalContent()
|
||||
if !strings.Contains(content, "timed out") || !strings.Contains(content, "run_shell_command") {
|
||||
t.Fatalf("finalContent = %q", content)
|
||||
}
|
||||
if turn.hasPendingTools() {
|
||||
t.Fatal("pending tools should be cleared after timeout")
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("pending tool timeout did not release turn")
|
||||
}
|
||||
|
||||
func TestIFlowTurnTimerResetsOnPartialToolCompletion(t *testing.T) {
|
||||
timeout := 100 * time.Millisecond
|
||||
|
||||
var cancelled atomic.Bool
|
||||
turn := &iflowTurn{
|
||||
cancel: func() { cancelled.Store(true) },
|
||||
pendingTimeout: timeout,
|
||||
pendingToolIDs: make(map[string]struct{}),
|
||||
pendingTools: make(map[string]iflowToolUse),
|
||||
seenToolIDs: make(map[string]struct{}),
|
||||
doneToolIDs: make(map[string]struct{}),
|
||||
}
|
||||
defer turn.stopResultTimer()
|
||||
|
||||
turn.addPendingTools([]iflowToolUse{
|
||||
{ID: "write_file:1", Name: "write_file"},
|
||||
{ID: "run_shell_command:2", Name: "run_shell_command"},
|
||||
})
|
||||
|
||||
// Wait 70ms (>50% of timeout), then complete one tool
|
||||
time.Sleep(70 * time.Millisecond)
|
||||
turn.completeTools([]iflowToolResult{{ID: "write_file:1", Output: "ok"}})
|
||||
|
||||
// Timer was reset — wait another 70ms; should NOT have timed out yet
|
||||
time.Sleep(70 * time.Millisecond)
|
||||
if turn.readyForResult() {
|
||||
t.Fatal("timer should have been reset; should not be ready yet")
|
||||
}
|
||||
|
||||
// Now wait for the full reset timeout to expire
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if turn.readyForResult() {
|
||||
if !cancelled.Load() {
|
||||
t.Fatal("expected cancel to be called")
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timer did not fire after reset")
|
||||
}
|
||||
|
||||
func TestIFlowSessionCustomToolTimeout(t *testing.T) {
|
||||
sess, err := newIFlowSession(context.Background(), "echo", "/tmp", "", "yolo", "", nil, 300)
|
||||
if err != nil {
|
||||
t.Fatalf("newIFlowSession: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
if got := sess.pendingToolTimeout(); got != 300*time.Second {
|
||||
t.Fatalf("pendingToolTimeout = %v, want 300s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIFlowSessionDefaultToolTimeout(t *testing.T) {
|
||||
sess, err := newIFlowSession(context.Background(), "echo", "/tmp", "", "yolo", "", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("newIFlowSession: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
if got := sess.pendingToolTimeout(); got != iflowPendingToolTimeout {
|
||||
t.Fatalf("pendingToolTimeout = %v, want %v", got, iflowPendingToolTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIFlowSessionPendingToolTimeoutClearsBusyState(t *testing.T) {
|
||||
oldTimeout := iflowPendingToolTimeout
|
||||
oldDefaultTimeout := iflowPendingToolTimeoutDefaultMode
|
||||
iflowPendingToolTimeout = 80 * time.Millisecond
|
||||
iflowPendingToolTimeoutDefaultMode = 80 * time.Millisecond
|
||||
defer func() { iflowPendingToolTimeout = oldTimeout }()
|
||||
defer func() { iflowPendingToolTimeoutDefaultMode = oldDefaultTimeout }()
|
||||
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
workDir := filepath.Join(t.TempDir(), "work")
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll workDir: %v", err)
|
||||
}
|
||||
|
||||
projectKey := iflowProjectKey(iflowResolvedWorkDir(workDir))
|
||||
t.Setenv("IFLOW_TEST_PROJECT_KEY", projectKey)
|
||||
|
||||
cmdPath := filepath.Join(t.TempDir(), "fake-iflow.sh")
|
||||
script := `#!/bin/sh
|
||||
set -eu
|
||||
sid="session-test"
|
||||
session_dir="$HOME/.iflow/projects/$IFLOW_TEST_PROJECT_KEY"
|
||||
mkdir -p "$session_dir"
|
||||
transcript="$session_dir/$sid.jsonl"
|
||||
cat >>"$transcript" <<'EOF'
|
||||
{"sessionId":"session-test","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"run_shell_command:1","name":"run_shell_command","input":{"command":"ls -la"}}]}}
|
||||
{"sessionId":"session-test","type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}
|
||||
EOF
|
||||
while :; do sleep 1; done
|
||||
`
|
||||
if err := os.WriteFile(cmdPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile fake iflow: %v", err)
|
||||
}
|
||||
|
||||
sess, err := newIFlowSession(context.Background(), cmdPath, workDir, "", "default", "", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("newIFlowSession: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
if err := sess.Send("执行ls", nil, nil); err != nil {
|
||||
t.Fatalf("Send #1: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.After(5 * time.Second)
|
||||
gotResult := false
|
||||
for !gotResult {
|
||||
select {
|
||||
case ev := <-sess.Events():
|
||||
if ev.Type != "result" {
|
||||
continue
|
||||
}
|
||||
gotResult = true
|
||||
if !strings.Contains(ev.Content, "timed out") {
|
||||
t.Fatalf("result content = %q", ev.Content)
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("timeout waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
if err := sess.Send("第二条消息", nil, nil); err != nil {
|
||||
if strings.Contains(err.Error(), "busy") {
|
||||
t.Fatalf("session still busy after timeout result: %v", err)
|
||||
}
|
||||
t.Fatalf("Send #2 failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIFlowSession_ContinueSessionTreatedAsFresh(t *testing.T) {
|
||||
s, err := newIFlowSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("newIFlowSession: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if got := s.CurrentSessionID(); got != "" {
|
||||
t.Errorf("ContinueSession should be treated as fresh: sessionID = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user