初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+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)
}
}