Files
AskaEth 2d211c2d16 feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)
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)。
2026-10-06 21:16:23 +08:00

405 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}