mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
refactor(agent/core): 3 个黑箱判据迁到 core/test + 去掉一处白盒
## 迁移了什么
stages_test.go → internal/agent/core/test/
stages_plugin_test.go → internal/agent/core/test/
stages_tooldef_test.go → internal/agent/core/test/
包名 `core` → `test`。`agent/core` 目录少 3 个测试文件(该目录原有
196 个文件,其中 144 个是测试 —— 这是分步收敛的第一批)。
## 顺带去掉一处白盒
`stages_plugin_test.go` 原本这样断言"stage 表已清空":
h.mu.RLock()
_, exists := h.stages[sdk.StageAfterToolcall]
h.mu.RUnlock()
直接读**私有互斥锁和私有字段**。而 `UnregisterPluginStages` 的返回值
已经表达了同一件事。改为:
if n := h.UnregisterPluginStages("solo"); n != 1 { ... }
if n := h.UnregisterPluginStages("solo"); n != 0 { ... }
第二次摘除必须得 0 —— 如果内部表没清空,第二次仍会"摘到"残留计数。
这是黑箱可观测的等价断言。
变异验证:让 `UnregisterPluginStages` 假装摘除但不动表
(`if true { return 1 }`)⇒ 实测红:
--- FAIL: TestUnregisterPluginStages_RemovesOnlyThatPlugin
--- FAIL: TestUnregisterPluginStages_DropsEmptyStage
★ 重复摘除应得 0(说明内部已清空),实际 1
## 为什么只迁 3 个(agent/core 有 143 个测试文件)
先按「对源码未导出标识符的引用数」分档,实测:
0 引用 11 个文件
1 引用 约 20 个
... 其余逐步升高
但"0 引用"不等于"能迁" —— 逐个实测发现四种拦路虎:
① `newScrubRuntime` / `withScrubRuntime` / `newRTTestAgent` ——
包内测试辅助,其中 `withScrubRuntime(a *Agent, rt)` 直接设私有字段
② `computeTokenBudget` —— 未导出方法(tokenbudget_test 必须留包内)
③ `filepath.Glob("*.go")` —— 扫描"当前目录源码"的判据
(childkind_test),迁到子目录后会 glob 到空目录而**静默失效**
④ `newPreemptAgent` 等跨文件辅助
③ 尤其值得记:它不是编译错误,是**判据悄悄失去对象**。
## 踩到的坑(记下来避免重犯)
第一次批量迁 11 个文件、再用脚本自动加 `core.` 前缀,结果把
`import "…/internal/agent/core"` 本身也改了(变成 `core.core.core`),
还误伤了 `strconv.Itoa` 等标准库调用。批量文本替换在"加包名前缀"
这种场景下不可靠 —— 它会改到导入路径、注释、标准库符号。
最终改为**一次只迁一个、编译器逐步报错、手工修**。慢,但不会留下
半成品。agent/core 已完全还原过一次(git checkout HEAD)。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 64 包全过,0 FAIL
(core/test 是新包,63 → 64)
core/test 实跑用例数 17(确认不是空壳)
干净检出(worktree) build + core/... 全绿
## 后续
agent/core 剩余约 140 个测试文件,按上述四类拦路虎分批:
· 纯黑箱可迁的先迁
· 需要导出入口的,评估"导出是否合理"(如 FrontendAsset 那样)
· 真白盒的(改私有状态、测内部算法)加文件头说明为什么必须白盒
This commit is contained in:
100
internal/agent/core/test/stages_plugin_test.go
Normal file
100
internal/agent/core/test/stages_plugin_test.go
Normal file
@ -0,0 +1,100 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// stage handler 必须能按插件成组摘除。
|
||||
//
|
||||
// 修复前 StageHost.stages 只存匿名函数,没有归属信息:
|
||||
// 插件崩溃/卸载后它的 handler 永远留在表里,每轮 RunStage 都被并发调起并
|
||||
// 打向已死进程;重启后新 handler 追加进来,旧的仍不退场——
|
||||
// 错误与重复执行随重启次数线性累积。
|
||||
func TestUnregisterPluginStages_RemovesOnlyThatPlugin(t *testing.T) {
|
||||
h := core.NewStageHost()
|
||||
|
||||
var aRan, bRan, coreRan int
|
||||
h.RegisterStageFor("a", sdk.StagePreAction, func(*sdk.StageContext) error { aRan++; return nil })
|
||||
h.RegisterStageFor("b", sdk.StagePreAction, func(*sdk.StageContext) error { bRan++; return nil })
|
||||
// 内核自身注册的 handler(无归属)不该被插件摘除波及
|
||||
h.RegisterStage(sdk.StagePreAction, func(*sdk.StageContext) error { coreRan++; return nil })
|
||||
|
||||
h.RunStage(sdk.StagePreAction, &sdk.StageContext{})
|
||||
if aRan != 1 || bRan != 1 || coreRan != 1 {
|
||||
t.Fatalf("首轮应全部执行,a=%d b=%d core=%d", aRan, bRan, coreRan)
|
||||
}
|
||||
|
||||
if n := h.UnregisterPluginStages("a"); n != 1 {
|
||||
t.Errorf("应摘除 1 个 handler,实际 %d", n)
|
||||
}
|
||||
|
||||
h.RunStage(sdk.StagePreAction, &sdk.StageContext{})
|
||||
if aRan != 1 {
|
||||
t.Errorf("已摘除的插件 handler 不该再被调用,实际执行 %d 次", aRan)
|
||||
}
|
||||
if bRan != 2 || coreRan != 2 {
|
||||
t.Errorf("其他 handler 应照常执行,b=%d core=%d", bRan, coreRan)
|
||||
}
|
||||
}
|
||||
|
||||
// 摘除某插件的最后一个 handler 后,该 stage 应从表中消失(RunStage 直接短路)。
|
||||
func TestUnregisterPluginStages_DropsEmptyStage(t *testing.T) {
|
||||
h := core.NewStageHost()
|
||||
h.RegisterStageFor("solo", sdk.StageAfterToolcall, func(*sdk.StageContext) error { return nil })
|
||||
|
||||
// ★ 2026-11-04:原先这里用 `h.mu.RLock(); h.stages[...]` 直接读私有字段,
|
||||
// 判据耦合了内部实现。`UnregisterPluginStages` 的返回值已经表达了同一件事
|
||||
// (摘除了几个),而那才是对外承诺。
|
||||
//
|
||||
// 补一条黑箱可观测的断言:摘除后再摘一次必须得到 0 —— 如果内部表没清空,
|
||||
// 第二次仍会"摘到"残留计数。
|
||||
if n := h.UnregisterPluginStages("solo"); n != 1 {
|
||||
t.Fatalf("应摘除 1 个,实际 %d", n)
|
||||
}
|
||||
if n := h.UnregisterPluginStages("solo"); n != 0 {
|
||||
t.Fatalf("★ 重复摘除应得 0(说明内部已清空),实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 空插件名不得误摘内核自身注册的 handler。
|
||||
func TestUnregisterPluginStages_EmptyNameIsNoop(t *testing.T) {
|
||||
h := core.NewStageHost()
|
||||
ran := 0
|
||||
h.RegisterStage(sdk.StagePreAction, func(*sdk.StageContext) error { ran++; return nil })
|
||||
|
||||
if n := h.UnregisterPluginStages(""); n != 0 {
|
||||
t.Errorf("空插件名应是 no-op,实际摘除 %d", n)
|
||||
}
|
||||
h.RunStage(sdk.StagePreAction, &sdk.StageContext{})
|
||||
if ran != 1 {
|
||||
t.Errorf("内核 handler 应保留并执行,实际 %d 次", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// 工具与 stage 的摘除互不干扰:都摘完后两者皆空。
|
||||
func TestUnregisterPluginToolsAndStages_Together(t *testing.T) {
|
||||
h := core.NewStageHost()
|
||||
if err := h.RegisterTool("demo_run", sdk.ToolDef{Name: "demo_run", Plugin: "demo"},
|
||||
func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.RegisterStageFor("demo", sdk.StagePreAction, func(*sdk.StageContext) error { return nil })
|
||||
|
||||
h.UnregisterPluginTools("demo")
|
||||
h.UnregisterPluginStages("demo")
|
||||
|
||||
if h.ToolCount() != 0 {
|
||||
t.Errorf("工具应已摘除,实际 %d", h.ToolCount())
|
||||
}
|
||||
if h.ToolPlugin("demo_run") != "" {
|
||||
t.Error("工具→插件映射应清空")
|
||||
}
|
||||
// 摘除后可重新注册同名工具(重启路径的前提)
|
||||
if err := h.RegisterTool("demo_run", sdk.ToolDef{Name: "demo_run", Plugin: "demo"},
|
||||
func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil {
|
||||
t.Errorf("摘除后应可重新注册同名工具,实际: %v", err)
|
||||
}
|
||||
}
|
||||
251
internal/agent/core/test/stages_test.go
Normal file
251
internal/agent/core/test/stages_test.go
Normal file
@ -0,0 +1,251 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func TestStageHostRegisterTool(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
|
||||
err := host.RegisterTool("test_tool", sdk.ToolDef{Name: "test_tool"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
defs := host.GetToolDefs()
|
||||
if len(defs) != 1 {
|
||||
t.Errorf("expected 1 tool def, got %d", len(defs))
|
||||
}
|
||||
if defs[0].Name != "test_tool" {
|
||||
t.Errorf("expected test_tool, got %s", defs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostRegisterToolDuplicate(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
host.RegisterTool("dup", sdk.ToolDef{Name: "dup"}, nil)
|
||||
err := host.RegisterTool("dup", sdk.ToolDef{Name: "dup"}, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error on duplicate tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostExecuteTool(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
|
||||
host.RegisterTool("hello", sdk.ToolDef{Name: "hello"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "world", nil
|
||||
})
|
||||
|
||||
result, err := host.ExecuteTool("hello", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if result.(string) != "world" {
|
||||
t.Errorf("expected world, got %v", result)
|
||||
}
|
||||
|
||||
_, err = host.ExecuteTool("nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostRunStage(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
|
||||
var called bool
|
||||
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := &sdk.StageContext{RawMessage: "hello"}
|
||||
host.RunStage(sdk.StageOnInput, ctx)
|
||||
|
||||
if !called {
|
||||
t.Error("stage handler was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostRunStageParallel(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
|
||||
// Two handlers that both try to set Response under Lock.
|
||||
// Only the first to acquire Lock actually wins; the second sees IsResponded() and skips.
|
||||
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
if ctx.Response == nil {
|
||||
resp := "from-first"
|
||||
ctx.Response = &resp
|
||||
}
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
})
|
||||
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
if ctx.Response == nil {
|
||||
resp := "from-second"
|
||||
ctx.Response = &resp
|
||||
}
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := &sdk.StageContext{RawMessage: "hello"}
|
||||
host.RunStage(sdk.StageOnInput, ctx)
|
||||
|
||||
if ctx.Response == nil {
|
||||
t.Fatal("expected a response to be set")
|
||||
}
|
||||
if *ctx.Response != "from-first" && *ctx.Response != "from-second" {
|
||||
t.Errorf("expected either from-first or from-second, got %s", *ctx.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostRunStageConcurrency(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
|
||||
var counter int32
|
||||
n := 10
|
||||
for i := 0; i < n; i++ {
|
||||
host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
|
||||
atomic.AddInt32(&counter, 1)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
host.RunStage(sdk.StageAfterOutput, &sdk.StageContext{})
|
||||
|
||||
if int(counter) != n {
|
||||
t.Errorf("expected %d handlers called, got %d", n, counter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostRunStageAll(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
|
||||
var mu sync.Mutex
|
||||
count := 0
|
||||
host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
|
||||
mu.Lock()
|
||||
count++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
|
||||
mu.Lock()
|
||||
count++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
host.RunStage(sdk.StageAfterOutput, &sdk.StageContext{})
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 handlers called, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostEmpty(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
|
||||
if host.ToolCount() != 0 {
|
||||
t.Errorf("expected 0 tools, got %d", host.ToolCount())
|
||||
}
|
||||
|
||||
defs := host.GetToolDefs()
|
||||
if len(defs) != 0 {
|
||||
t.Errorf("expected 0 tool defs, got %d", len(defs))
|
||||
}
|
||||
|
||||
_, err := host.ExecuteTool("anything", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error on empty host")
|
||||
}
|
||||
|
||||
host.RunStage(sdk.StageOnInput, &sdk.StageContext{})
|
||||
}
|
||||
|
||||
func TestStageHostMultipleTools(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
|
||||
host.RegisterTool("tool1", sdk.ToolDef{Name: "tool1"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "from_p1", nil
|
||||
})
|
||||
host.RegisterTool("tool2", sdk.ToolDef{Name: "tool2"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "from_p2", nil
|
||||
})
|
||||
|
||||
if host.ToolCount() != 2 {
|
||||
t.Errorf("expected 2 tools, got %d", host.ToolCount())
|
||||
}
|
||||
|
||||
r1, _ := host.ExecuteTool("tool1", nil)
|
||||
if r1.(string) != "from_p1" {
|
||||
t.Errorf("expected from_p1, got %v", r1)
|
||||
}
|
||||
|
||||
r2, _ := host.ExecuteTool("tool2", nil)
|
||||
if r2.(string) != "from_p2" {
|
||||
t.Errorf("expected from_p2, got %v", r2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostToolDefLookup(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
host.RegisterTool("tool_a", sdk.ToolDef{Name: "tool_a", NoMemory: true}, nil)
|
||||
host.RegisterTool("tool_b", sdk.ToolDef{Name: "tool_b"}, nil)
|
||||
|
||||
def := host.ToolDef("tool_a")
|
||||
if def == nil {
|
||||
t.Fatal("expected tool_a to be found")
|
||||
}
|
||||
if !def.NoMemory {
|
||||
t.Error("tool_a should have NoMemory=true")
|
||||
}
|
||||
|
||||
def = host.ToolDef("tool_b")
|
||||
if def == nil {
|
||||
t.Fatal("expected tool_b to be found")
|
||||
}
|
||||
if def.NoMemory {
|
||||
t.Error("tool_b should have NoMemory=false")
|
||||
}
|
||||
|
||||
def = host.ToolDef("nonexistent")
|
||||
if def != nil {
|
||||
t.Errorf("expected nil for nonexistent tool, got %v", def)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostToolDefNoMemoryStored(t *testing.T) {
|
||||
host := core.NewStageHost()
|
||||
host.RegisterTool("mem_tool", sdk.ToolDef{Name: "mem_tool", NoMemory: true}, nil)
|
||||
host.RegisterTool("normal_tool", sdk.ToolDef{Name: "normal_tool", NoMemory: false}, nil)
|
||||
|
||||
defs := host.GetToolDefs()
|
||||
found := map[string]bool{}
|
||||
for _, d := range defs {
|
||||
found[d.Name] = d.NoMemory
|
||||
}
|
||||
|
||||
if v, ok := found["mem_tool"]; !ok {
|
||||
t.Error("mem_tool not found in defs")
|
||||
} else if !v {
|
||||
t.Error("mem_tool.NoMemory should be true")
|
||||
}
|
||||
|
||||
if v, ok := found["normal_tool"]; !ok {
|
||||
t.Error("normal_tool not found in defs")
|
||||
} else if v {
|
||||
t.Error("normal_tool.NoMemory should be false")
|
||||
}
|
||||
}
|
||||
98
internal/agent/core/test/stages_tooldef_test.go
Normal file
98
internal/agent/core/test/stages_tooldef_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// 无拷贝查询必须与 ToolDef(...).字段 **完全等价**。
|
||||
//
|
||||
// 这类优化最危险的失败模式是"优化悄悄改了语义":比如并发判据原本
|
||||
// 读 ParallelSafe,优化后读成了别的字段,于是工具能并发的批次悄悄
|
||||
// 退化成串行 —— 没有任何报错。
|
||||
func TestNoCopyQueriesMatchToolDef(t *testing.T) {
|
||||
sh := core.NewStageHost()
|
||||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||||
cases := []sdk.ToolDef{
|
||||
{Name: "plain"},
|
||||
{Name: "parallel", ParallelSafe: true},
|
||||
{Name: "serial", Serial: true},
|
||||
{Name: "both", ParallelSafe: true, Serial: true},
|
||||
{Name: "nomem", NoMemory: true},
|
||||
{Name: "all", ParallelSafe: true, NoMemory: true},
|
||||
{Name: "serial_nomem", Serial: true, NoMemory: true},
|
||||
}
|
||||
for _, d := range cases {
|
||||
if err := sh.RegisterTool(d.Name, d, noop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, d := range cases {
|
||||
def := sh.ToolDef(d.Name)
|
||||
if def == nil {
|
||||
t.Fatalf("%s: ToolDef 返回 nil", d.Name)
|
||||
}
|
||||
safe, found := sh.ConcurrencySafeOf(d.Name)
|
||||
if !found {
|
||||
t.Errorf("%s: ConcurrencySafeOf 未找到", d.Name)
|
||||
continue
|
||||
}
|
||||
if want := def.ParallelSafe && !def.Serial; safe != want {
|
||||
t.Errorf("%s: ConcurrencySafeOf=%v,ToolDef 算出的应为 %v(ParallelSafe=%v Serial=%v)",
|
||||
d.Name, safe, want, def.ParallelSafe, def.Serial)
|
||||
}
|
||||
nm, ok := sh.NoMemoryOf(d.Name)
|
||||
if !ok {
|
||||
t.Errorf("%s: NoMemoryOf 未找到", d.Name)
|
||||
continue
|
||||
}
|
||||
if nm != def.NoMemory {
|
||||
t.Errorf("%s: NoMemoryOf=%v,ToolDef.NoMemory=%v", d.Name, nm, def.NoMemory)
|
||||
}
|
||||
if sh.HasTool(d.Name) != true {
|
||||
t.Errorf("%s: HasTool 应为 true", d.Name)
|
||||
}
|
||||
}
|
||||
// 不存在的工具
|
||||
if safe, found := sh.ConcurrencySafeOf("nope"); found || safe {
|
||||
t.Errorf("不存在的工具:found=%v safe=%v,应为 false/false", found, safe)
|
||||
}
|
||||
if sh.HasTool("nope") {
|
||||
t.Error("HasTool 对不存在的工具返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// 并发查询必须无竞态,且与串行结果一致。
|
||||
func TestNoCopyQueriesConcurrent(t *testing.T) {
|
||||
sh := core.NewStageHost()
|
||||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||||
for i := 0; i < 50; i++ {
|
||||
name := "t" + string(rune('a'+i%26)) + string(rune('0'+i/26))
|
||||
_ = sh.RegisterTool(name, sdk.ToolDef{Name: name, ParallelSafe: i%2 == 0}, noop)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 32; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
name := "t" + string(rune('a'+i%26)) + string(rune('0'+i/26))
|
||||
safe, found := sh.ConcurrencySafeOf(name)
|
||||
if !found {
|
||||
t.Errorf("并发下 %s 未找到", name)
|
||||
return
|
||||
}
|
||||
if safe != (i%2 == 0) {
|
||||
t.Errorf("并发下 %s safe=%v,期望 %v", name, safe, i%2 == 0)
|
||||
return
|
||||
}
|
||||
_ = sh.HasTool(name)
|
||||
_, _ = sh.NoMemoryOf(name)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user