package backthink import ( "context" "errors" "strings" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/urgency" ) // 本文件是 design/12 §N C.3 工具面的判据(C.8 判据 7 / 8 与 C.3 的入参契约)。 // recordingWaker 是 Waker 的替身:记下每一次请求,并按脚本回一张收据或一个错误。 // 「Waker 一次都没被调用」正是 C.8 判据 7 的断言点,因此调用记录是它存在的理由。 type recordingWaker struct { mu sync.Mutex reqs []SelfWakeRequest receipt SelfWakeReceipt err error } // WakeNow 记录请求并返回脚本结果(默认零值收据 = 被上限拒绝)。 func (w *recordingWaker) WakeNow(_ context.Context, req SelfWakeRequest) (SelfWakeReceipt, error) { w.mu.Lock() defer w.mu.Unlock() w.reqs = append(w.reqs, req) if w.err != nil { return SelfWakeReceipt{}, w.err } return w.receipt, nil } // calls 返回已记录的请求快照。 func (w *recordingWaker) calls() []SelfWakeRequest { w.mu.Lock() defer w.mu.Unlock() out := make([]SelfWakeRequest, len(w.reqs)) copy(out, w.reqs) return out } // groupCtx 是群聊环境(第二道身份闸必须拒掉它)。stranger 同理,用一个就够。 func groupCtx() context.Context { return loop.WithSessionEnv(context.Background(), loop.SessionEnv{ActorEnv: restrict.ActorGroupMember}) } // okResult 把工具结果断言成 map 并返回(类型不对就 Fatal)。 func okResult(t *testing.T, val any) map[string]any { t.Helper() m, ok := val.(map[string]any) if !ok { t.Fatalf("工具结果类型 = %T, want map[string]any", val) } return m } // TestThinkTool_OwnerQueuesAndReportsReceipt 断言主路径:主人环境 + 合法入参 ⇒ 请求原样 // 到达 Waker,收据被原样映射成模型可读的结果(含 ok/queued/coalesced/used/limit/windowSec)。 func TestThinkTool_OwnerQueuesAndReportsReceipt(t *testing.T) { t.Parallel() w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} tt := NewThinkTool(w) val, err := tt.Execute(ownerCtx(), map[string]any{ thinkArgReason: "稍后回看她提过的日程", thinkArgUrgency: "warning", }) if err != nil { t.Fatalf("Execute: %v", err) } reqs := w.calls() if len(reqs) != 1 { t.Fatalf("Waker 被调用 %d 次, want 1", len(reqs)) } if reqs[0].Reason != "稍后回看她提过的日程" { t.Fatalf("理由 = %q, want 原样透传", reqs[0].Reason) } if reqs[0].Declared != urgency.LevelWarning { t.Fatalf("声明档位 = %s, want warning", reqs[0].Declared) } m := okResult(t, val) for k, want := range map[string]any{ "ok": true, "queued": true, "coalesced": false, "reason": "", "used": 1, "limit": 4, "windowSec": 3600, } { if got := m[k]; got != want { t.Fatalf("结果 %s = %#v, want %#v(整张结果:%#v)", k, got, want, m) } } } // TestThinkTool_MissingUrgencyMeansRoutine 断言省略 urgency 就是例行档(零值), // 绝不被猜成更高档 —— 「猜错档位」的代价是让一轮思考穿透安静时段。 func TestThinkTool_MissingUrgencyMeansRoutine(t *testing.T) { t.Parallel() w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} tt := NewThinkTool(w) if _, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "只是想再想想"}); err != nil { t.Fatalf("Execute: %v", err) } if got := w.calls()[0].Declared; got != urgency.LevelRoutine { t.Fatalf("省略 urgency 的档位 = %s, want routine", got) } } // TestThinkTool_GroupMemberRefusedWithoutCallingWaker 是 C.8 判据 7:群聊(非主人) // 环境必须返回包装后的 ErrSelfWakeNotOwner,且 **Waker 一次都没被调用**。 // // 变异:删掉 Execute 里的 owner 判定 ⇒ 本用例变红(群聊能自排)。 func TestThinkTool_GroupMemberRefusedWithoutCallingWaker(t *testing.T) { t.Parallel() w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} tt := NewThinkTool(w) _, err := tt.Execute(groupCtx(), map[string]any{thinkArgReason: "偷排一轮"}) if !errors.Is(err, ErrSelfWakeNotOwner) { t.Fatalf("群聊环境的错误 = %v, want 包装后的 ErrSelfWakeNotOwner", err) } if len(w.calls()) != 0 { t.Fatalf("群聊环境触到了 Waker(它必须「不做事」):%+v", w.calls()) } } // TestThinkTool_StrangerRefused 断言陌生人环境同样被拒(三种 env 里两个受限档都要拦)。 func TestThinkTool_StrangerRefused(t *testing.T) { t.Parallel() w := &recordingWaker{} tt := NewThinkTool(w) ctx := loop.WithSessionEnv(context.Background(), loop.SessionEnv{ActorEnv: restrict.ActorStranger}) if _, err := tt.Execute(ctx, map[string]any{thinkArgReason: "x"}); !errors.Is(err, ErrSelfWakeNotOwner) { t.Fatalf("陌生人环境的错误 = %v, want ErrSelfWakeNotOwner", err) } if len(w.calls()) != 0 { t.Fatalf("陌生人环境触到了 Waker:%+v", w.calls()) } } // TestThinkTool_UrgencyIsAClosedSet 是 C.8 判据 8:只有闭集里的**精确字面量**被认, // 其余(含近义词、前缀、大小写、带尾随空格)一律零值 routine。 // // 变异:把判定放宽成前缀/子串匹配(例如 strings.HasPrefix(raw, "crit"))⇒ // "critical " / "crit" 会被读成 critical,本用例变红。 func TestThinkTool_UrgencyIsAClosedSet(t *testing.T) { t.Parallel() cases := []struct { in string want urgency.Level }{ {"routine", urgency.LevelRoutine}, {"warning", urgency.LevelWarning}, {"critical", urgency.LevelCritical}, {"", urgency.LevelRoutine}, {"urgent", urgency.LevelRoutine}, {"紧急", urgency.LevelRoutine}, {"CRITICAL", urgency.LevelRoutine}, {"critical ", urgency.LevelRoutine}, {"crit", urgency.LevelRoutine}, {"warn", urgency.LevelRoutine}, } for _, c := range cases { w := &recordingWaker{} tt := NewThinkTool(w) if _, err := tt.Execute(ownerCtx(), map[string]any{ thinkArgReason: "看看", thinkArgUrgency: c.in, }); err != nil { t.Fatalf("urgency=%q 时 Execute: %v", c.in, err) } if got := w.calls()[0].Declared; got != c.want { t.Fatalf("urgency=%q ⇒ %s, want %s", c.in, got, c.want) } } } // TestThinkTool_ReasonRequired 断言理由缺省 / 空白 / 类型不对都是参数错误,且 // **不替她编一个理由**(Waker 不被调用)。 func TestThinkTool_ReasonRequired(t *testing.T) { t.Parallel() bad := []struct { name string args any }{ {"缺字段", map[string]any{}}, {"空串", map[string]any{thinkArgReason: ""}}, {"纯空白", map[string]any{thinkArgReason: " \n\t "}}, {"非字符串", map[string]any{thinkArgReason: 42}}, {"整份参数不是对象", "just a string"}, } for _, c := range bad { w := &recordingWaker{} tt := NewThinkTool(w) _, err := tt.Execute(ownerCtx(), c.args) if !errors.Is(err, ErrSelfWakeToolArgs) { t.Fatalf("%s:错误 = %v, want ErrSelfWakeToolArgs", c.name, err) } if len(w.calls()) != 0 { t.Fatalf("%s:Waker 被调用了 %+v(不该发生)", c.name, w.calls()) } } } // TestThinkTool_ReasonTrimmedAndTruncated 断言理由去首尾空白、按 **rune** 截断(不腰斩 // 多字节字符),并把截断如实回报 —— 不静默丢弃。 func TestThinkTool_ReasonTrimmedAndTruncated(t *testing.T) { t.Parallel() w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} tt := NewThinkTool(w) long := strings.Repeat("想", selfWakeReasonMaxRunes+50) val, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: " " + long + " "}) if err != nil { t.Fatalf("Execute: %v", err) } got := w.calls()[0].Reason if len([]rune(got)) != selfWakeReasonMaxRunes { t.Fatalf("理由长度 = %d rune, want %d", len([]rune(got)), selfWakeReasonMaxRunes) } if !strings.HasPrefix(got, "想") { t.Fatalf("截断把多字节字符切坏了:%q", got) } if m := okResult(t, val); m["reasonTruncated"] != true { t.Fatalf("截断没有如实回报:%#v", m) } // 未超长时首尾空白被去掉,且不报截断。 val2, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: " 回看日程 "}) if err != nil { t.Fatalf("Execute: %v", err) } if got := w.calls()[1].Reason; got != "回看日程" { t.Fatalf("理由 = %q, want 去空白后的 %q", got, "回看日程") } if m := okResult(t, val2); m["reasonTruncated"] != false { t.Fatalf("未截断却报了截断:%#v", m) } } // TestThinkTool_CappedIsABusinessResultNotAnError 断言「被上限拒绝」是**合法业务结果** // (ok:false、err 为 nil):她要看得见拒绝才会停止连排;把它伪装成 error 或反过来 // 把拒绝伪装成成功,都会让她误判。 func TestThinkTool_CappedIsABusinessResultNotAnError(t *testing.T) { t.Parallel() w := &recordingWaker{receipt: SelfWakeReceipt{ Accepted: false, Coalesced: false, Reason: "已达自我唤醒上限(本窗口 4/4,窗口 1h0m0s),本轮未排队", Used: 4, Limit: 4, WindowSec: 3600, }} tt := NewThinkTool(w) val, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "再想一轮"}) if err != nil { t.Fatalf("被上限拒绝必须是业务结果而不是 error,got %v", err) } m := okResult(t, val) if m["ok"] != false || m["queued"] != false { t.Fatalf("拒绝的收据映射错了:%#v", m) } if m["used"] != 4 || m["limit"] != 4 { t.Fatalf("拒绝没带上用量口径:%#v", m) } if s, _ := m["reason"].(string); !strings.Contains(s, "上限") { t.Fatalf("拒绝说明没原样带出:%#v", m) } } // TestThinkTool_CoalescedReportsAcceptedButNotQueued 断言合并语义在工具面上可分辨: // 合并 = ok:true + coalesced:true + queued:false(不额外产生一轮)。 func TestThinkTool_CoalescedReportsAcceptedButNotQueued(t *testing.T) { t.Parallel() w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Coalesced: true, Used: 2, Limit: 4, WindowSec: 3600}} tt := NewThinkTool(w) val, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "合并到在队那轮"}) if err != nil { t.Fatalf("Execute: %v", err) } m := okResult(t, val) if m["ok"] != true || m["coalesced"] != true || m["queued"] != false { t.Fatalf("合并的收据映射错了:%#v", m) } } // TestThinkTool_WakerErrorsSurfaceAsErrors 断言服务侧的真实故障(未 Start、未接线) // 是 error,**不是** ok:true —— 红线:不伪造成功。 func TestThinkTool_WakerErrorsSurfaceAsErrors(t *testing.T) { t.Parallel() w := &recordingWaker{err: ErrSelfWakeUnavailable} tt := NewThinkTool(w) if _, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "x"}); !errors.Is(err, ErrSelfWakeUnavailable) { t.Fatalf("未 Start 的错误 = %v, want 包装后的 ErrSelfWakeUnavailable", err) } nilWaker := NewThinkTool(nil) if _, err := nilWaker.Execute(ownerCtx(), map[string]any{thinkArgReason: "x"}); !errors.Is(err, ErrSelfWakeUnavailable) { t.Fatalf("未接线 Waker 的错误 = %v, want ErrSelfWakeUnavailable", err) } } // TestThinkTool_ParametersDeclareTheClosedSet 断言工具面把闭集**广告**出去: // reason 必填、urgency 的 enum 恰是三个档位字面量(与 urgency 词汇同源)。 func TestThinkTool_ParametersDeclareTheClosedSet(t *testing.T) { t.Parallel() params, err := NewThinkTool(&recordingWaker{}).Parameters() if err != nil { t.Fatalf("Parameters: %v", err) } props, ok := params["properties"].(map[string]any) if !ok { t.Fatalf("properties 类型 = %T", params["properties"]) } if _, ok := props[thinkArgReason]; !ok { t.Fatalf("schema 里没有 %q:%#v", thinkArgReason, props) } req, ok := params["required"].([]any) if !ok || len(req) != 1 || req[0] != thinkArgReason { t.Fatalf("required = %#v, want 只含 %q", params["required"], thinkArgReason) } urgencyProp, ok := props[thinkArgUrgency].(map[string]any) if !ok { t.Fatalf("%s 属性类型 = %T", thinkArgUrgency, props[thinkArgUrgency]) } enum, ok := urgencyProp["enum"].([]any) if !ok { t.Fatalf("enum 类型 = %T, want []any", urgencyProp["enum"]) } want := map[string]bool{"routine": true, "warning": true, "critical": true} if len(enum) != len(want) { t.Fatalf("enum = %#v, want 恰好三个闭集取值", enum) } for _, v := range enum { s, _ := v.(string) if !want[s] { t.Fatalf("enum 里有闭集之外的取值 %#v(want %v)", v, want) } } } // TestThinkTool_ThroughDispatcher 走**真实分发器**(注册表 → JSON-Schema 校验 → // 工具执行),证明三件事: // 1. 注册进注册表后模型真的调得到,且合法入参一路走到 Waker; // 2. 群聊环境在**执行前**被第二道闸拒掉(Waker 不被触碰); // 3. 闭集之外的 urgency 被 schema 拦在 Execute 之前(工具面本来就是闭集), // 因此 Execute 里的闭集兜底只服务直接调用。 func TestThinkTool_ThroughDispatcher(t *testing.T) { t.Parallel() reg := registry.New() d, err := tool.New(reg) if err != nil { t.Fatalf("tool.New: %v", err) } w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} if err := d.Register(NewThinkTool(w)); err != nil { t.Fatalf("Register: %v", err) } cap, err := reg.Get(ThinkToolName) if err != nil { t.Fatalf("%s 没进能力注册表: %v", ThinkToolName, err) } if cap.Kind != registry.KindTool { t.Fatalf("能力类型 = %q, want %q", cap.Kind, registry.KindTool) } // 策略标记保持零值 = owner-only(design/11 §3 的 fail-closed 默认)。 if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger { t.Fatalf("%s 的策略必须保持 owner-only,got %+v", ThinkToolName, cap.Policy) } val, err := d.Invoke(ownerCtx(), ThinkToolName, map[string]any{ thinkArgReason: "稍后回看她的日程", thinkArgUrgency: "warning", }) if err != nil { t.Fatalf("Invoke(owner): %v", err) } if m := okResult(t, val); m["ok"] != true { t.Fatalf("Invoke(owner) 结果 = %#v", m) } if len(w.calls()) != 1 || w.calls()[0].Declared != urgency.LevelWarning { t.Fatalf("Waker 收到的请求 = %+v", w.calls()) } if _, err := d.Invoke(groupCtx(), ThinkToolName, map[string]any{thinkArgReason: "偷排"}); !errors.Is(err, ErrSelfWakeNotOwner) { t.Fatalf("Invoke(group) 错误 = %v, want ErrSelfWakeNotOwner", err) } if len(w.calls()) != 1 { t.Fatalf("群聊调用触到了 Waker:%+v", w.calls()) } if _, err := d.Invoke(ownerCtx(), ThinkToolName, map[string]any{ thinkArgReason: "x", thinkArgUrgency: "urgent", }); err == nil { t.Fatal("闭集之外的 urgency 必须被 schema 拒绝,而不是静默当成 routine") } if len(w.calls()) != 1 { t.Fatalf("被 schema 拒掉的调用触到了 Waker:%+v", w.calls()) } }