package server import ( "net/http" "os" "path/filepath" "testing" "time" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/bus" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/cmdbus" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/plugins" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/profiles" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/runmgr" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/runtime" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/settings" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/tools" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) // ---- N3 WP3:rewind HTTP 层(happy + busy 409)---- // happy:经 API 回滚被追踪写入,restored/skipped 上抛,文件内容退回。 func TestRewindFilesHTTPHappy(t *testing.T) { dir := t.TempDir() st, err := store.OpenSQLite(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) _ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}}) reg := tools.NewRegistry() reg.RegisterBuiltin() setSvc := settings.NewService(st) profSvc := profiles.New(st) profSvc.EnsureDefault() mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, &runtime.MockRunner{}, &runtime.MockSubagent{}, &runtime.MockRunner{}, &runtime.MockSubagent{}) ws := t.TempDir() mgr.SetWorkspaceRoot(ws) ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins")) srv := New(st, mgr, reg, profSvc, setSvc, ph) srv.Bus = cmdbus.New(st, nil, nil) h := srv.Handler() _, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "写个文件") // 等 run 终态(mock run 是异步的;不回等会撞到会话占用 409)。 deadline := time.Now().Add(2 * time.Second) for { run, err := st.GetRunByID(runID) if err == nil && (run.Status == core.RunCompleted || run.Status == core.RunFailed || run.Status == core.RunCancelled) { break } if time.Now().After(deadline) { t.Fatalf("run 未终态:%+v", run) } time.Sleep(10 * time.Millisecond) } // 找到用户消息(anchor)并造一条被追踪写入。 var userMsgID string msgs, err := st.ListMessages(convID) if err != nil { t.Fatal(err) } for _, m := range msgs { if m.Role == core.RoleUser { userMsgID = m.ID } } if userMsgID == "" { t.Fatal("no user message") } sandbox := filepath.Join(ws, store.OwnerDefault, convID) if err := os.MkdirAll(sandbox, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(sandbox, "tracked.txt"), []byte("写入后"), 0o644); err != nil { t.Fatal(err) } cpLog, err := mgr.OpenCheckpoints(store.OwnerDefault, convID) if err != nil { t.Fatal(err) } if err := cpLog.Record(userMsgID, sandbox, "tracked.txt", []byte("写入前"), true, []byte("写入后")); err != nil { t.Fatal(err) } // run 行已终态与释放会话占用之间有个 settle 窗口(CI 慢机上会撞 409): // 有界重试直到放行。 var code int var out map[string]any deadline2 := time.Now().Add(3 * time.Second) for { code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/rewind", map[string]any{"messageId": userMsgID}) if code != http.StatusConflict { break } if time.Now().After(deadline2) { break } time.Sleep(15 * time.Millisecond) } if code != http.StatusOK { t.Fatalf("rewind status %d: %v", code, out) } restored, _ := out["restored"].([]any) if len(restored) != 1 || restored[0] != "tracked.txt" { t.Fatalf("restored: %v", out) } body, err := os.ReadFile(filepath.Join(sandbox, "tracked.txt")) if err != nil || string(body) != "写入前" { t.Fatalf("文件未退回:%v %q", err, body) } } // busy:会话被占时回滚返回 409。 func TestRewindFilesHTTPBusy409(t *testing.T) { dir := t.TempDir() st, err := store.OpenSQLite(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) _ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}) reg := tools.NewRegistry() reg.RegisterBuiltin() setSvc := settings.NewService(st) profSvc := profiles.New(st) profSvc.EnsureDefault() release := make(chan struct{}) blocking := blockingRunner{block: make(chan struct{}, 1), release: release} defer close(release) mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, blocking, blocking, &runtime.MockRunner{}, &runtime.MockSubagent{}) t.Cleanup(mgr.Shutdown) ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins")) srv := New(st, mgr, reg, profSvc, setSvc, ph) srv.Bus = cmdbus.New(st, nil, nil) h := srv.Handler() _, convID := setupConversation(t, h) postMessage(t, h, convID, "先占住") var userMsgID string msgs, _ := st.ListMessages(convID) for _, m := range msgs { if m.Role == core.RoleUser { userMsgID = m.ID } } code, _ := doJSON(t, h, "POST", "/api/conversations/"+convID+"/rewind", map[string]any{"messageId": userMsgID}) if code != http.StatusConflict { t.Fatalf("busy rewind 应 409,得到 %d", code) } }