X2 C1-3(自排工具面与提示词): - request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门 (安全闸 + owner 判定:群聊/陌生人一律拒) - **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换) - 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴 - 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等) X3 收口(与 X2 共用 wire_services.go ⇒ 一并落): - 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份) - 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动) - a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点 - 5 个变异自检 顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
405 lines
15 KiB
Go
405 lines
15 KiB
Go
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())
|
||
}
|
||
}
|