初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+480
View File
@@ -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
}
+94
View File
@@ -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")
}
}
}
+211
View File
@@ -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()
}
+905
View File
@@ -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
}
+442
View File
@@ -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)
}
}