package tool import ( "bytes" "context" "log/slog" "strings" "sync" "testing" ) // softTool 是软判用例的夹具:它记录自己是否真的被执行过, // 使「软判不硬阻断」可以被直接断言,而不是靠返回值反推。 type softTool struct { name string mu sync.Mutex ran int } func (s *softTool) Name() string { return s.name } func (s *softTool) Description() string { return "soft judge fixture " + s.name } func (s *softTool) Parameters() (map[string]any, error) { return map[string]any{"type": "object", "properties": map[string]any{}}, nil } func (s *softTool) Execute(_ context.Context, _ any) (any, error) { s.mu.Lock() s.ran++ s.mu.Unlock() return "ran:" + s.name, nil } // runs 返回该工具被执行过的次数。 func (s *softTool) runs() int { s.mu.Lock() defer s.mu.Unlock() return s.ran } // softFixture 构建一个带两个工具的 dispatcher:terminal 在允许集内, // web-search 在外,并返回二者与软判记录收集器。 func softFixture(t *testing.T, opts ...Option) (*Dispatcher, *softTool, *softTool, func() []SoftJudgeEvent) { t.Helper() d, _ := newDispatcher(t, opts...) inside := &softTool{name: "terminal"} outside := &softTool{name: "web-search"} for _, tk := range []*softTool{inside, outside} { if err := d.Register(tk); err != nil { t.Fatalf("Register %q: %v", tk.name, err) } } var mu sync.Mutex var events []SoftJudgeEvent d.SetSoftJudgeSink(func(_ context.Context, ev SoftJudgeEvent) { mu.Lock() events = append(events, ev) mu.Unlock() }) read := func() []SoftJudgeEvent { mu.Lock() defer mu.Unlock() return append([]SoftJudgeEvent(nil), events...) } return d, inside, outside, read } // TestInvoke_OutsideAllowSet_SoftNotAllowedNotBlocked 锁住裁决 14.4.2 // 的「软判并回」:越出本轮允许集的调用不被硬阻断(确实执行了, // 能力≠边界),但结果里显式带上 not_allowed 提示回灌给模型, // 并留下一条可观测的判定记录(sink + 带来源的日志见下一个用例)。 func TestInvoke_OutsideAllowSet_SoftNotAllowedNotBlocked(t *testing.T) { t.Parallel() d, _, outside, events := softFixture(t) ctx := WithRoundAllowSet(context.Background()) PublishRoundAllowSet(ctx, []string{"terminal"}, "capacity:quiet") got, err := d.Invoke(ctx, "web-search", map[string]any{"q": "天气"}) if err != nil { t.Fatalf("Invoke outside the allow set must not hard-block, got %v", err) } if outside.runs() != 1 { t.Fatalf("tool ran %d times, want 1 (软判不得阻断执行)", outside.runs()) } soft, ok := got.(SoftNotAllowed) if !ok { t.Fatalf("result = %#v, want SoftNotAllowed(软结果并回)", got) } if soft.Tool != "web-search" { t.Fatalf("soft result tool = %q, want web-search", soft.Tool) } if soft.Result != "ran:web-search" { t.Fatalf("soft result payload = %#v, want the real tool result", soft.Result) } for _, want := range []string{"not_allowed", "web-search", "capacity:quiet"} { if !strings.Contains(soft.NotAllowed, want) { t.Fatalf("notice %q missing %q", soft.NotAllowed, want) } } read := events() if len(read) != 1 { t.Fatalf("soft judge events = %d, want 1 (%+v)", len(read), read) } if read[0].Tool != "web-search" || read[0].Source != "capacity:quiet" { t.Fatalf("event = %+v, want tool/source recorded", read[0]) } if read[0].Reason != soft.NotAllowed { t.Fatalf("event reason = %q, want the model-facing notice %q", read[0].Reason, soft.NotAllowed) } } // TestInvoke_InsideAllowSet_ResultUntouched 证明允许集内的调用 // 逐字节保持既有行为:原始结果、不包装、无软判记录。 func TestInvoke_InsideAllowSet_ResultUntouched(t *testing.T) { t.Parallel() d, inside, _, events := softFixture(t) ctx := WithRoundAllowSet(context.Background()) PublishRoundAllowSet(ctx, []string{"terminal"}, "capacity:quiet") got, err := d.Invoke(ctx, "terminal", map[string]any{}) if err != nil { t.Fatalf("Invoke: %v", err) } if got != "ran:terminal" { t.Fatalf("result = %#v, want the raw tool result", got) } if inside.runs() != 1 { t.Fatalf("tool ran %d times, want 1", inside.runs()) } if n := len(events()); n != 0 { t.Fatalf("soft judge events = %d, want 0 for an allowed call", n) } } // TestInvoke_NoAllowSetPublished_Unchanged 证明「未发布允许集」语义 // 为不做软判:未播种,以及播种但从未发布,两条路径都与接通前一致。 func TestInvoke_NoAllowSetPublished_Unchanged(t *testing.T) { t.Parallel() d, _, _, events := softFixture(t) // ① 完全没播种。 got, err := d.Invoke(context.Background(), "web-search", map[string]any{}) if err != nil { t.Fatalf("Invoke: %v", err) } if got != "ran:web-search" { t.Fatalf("result = %#v, want the raw tool result", got) } // ② 播种了但本轮没有发布(例如不经 capacity 过滤的内联流程)。 seeded := WithRoundAllowSet(context.Background()) if _, err := d.Invoke(seeded, "web-search", map[string]any{}); err != nil { t.Fatalf("Invoke: %v", err) } if n := len(events()); n != 0 { t.Fatalf("soft judge events = %d, want 0 without a published allow set", n) } } // TestSoftJudge_LogsWithSource 证明软判留下可观测的判定记录, // 且日志带模块来源(项目规范 §10),不是悄悄放行。 func TestSoftJudge_LogsWithSource(t *testing.T) { t.Parallel() var buf bytes.Buffer d, _, _, _ := softFixture(t, WithLogger(slog.New(slog.NewJSONHandler(&buf, nil)))) ctx := WithRoundAllowSet(context.Background()) PublishRoundAllowSet(ctx, []string{"terminal"}, "capacity:quiet") if _, err := d.Invoke(ctx, "web-search", map[string]any{}); err != nil { t.Fatalf("Invoke: %v", err) } line := buf.String() for _, want := range []string{`"source":"runtime/tool"`, "web-search", "capacity:quiet", "soft judge"} { if !strings.Contains(line, want) { t.Fatalf("log %q missing %q", line, want) } } } // TestPublishRoundAllowSet_CopiesNames 证明发布时会复制名单: // 调用方随后改自己那份切片,不得影响已发布的允许集。 func TestPublishRoundAllowSet_CopiesNames(t *testing.T) { t.Parallel() d, _, _, events := softFixture(t) names := []string{"terminal"} ctx := WithRoundAllowSet(context.Background()) PublishRoundAllowSet(ctx, names, "capacity:quiet") names[0] = "web-search" // 允许集仍是 [terminal],因此 web-search 依旧越界。 if _, err := d.Invoke(ctx, "web-search", map[string]any{}); err != nil { t.Fatalf("Invoke: %v", err) } if n := len(events()); n != 1 { t.Fatalf("soft judge events = %d, want 1 (published names must be a copy)", n) } } // TestWithRoundAllowSet_FreshHolderPerRun 证明重新播种得到独立持有者: // 子代理 Run 不得继承父级 Run 已发布的允许集(两者的工具面不同)。 func TestWithRoundAllowSet_FreshHolderPerRun(t *testing.T) { t.Parallel() outer := WithRoundAllowSet(context.Background()) PublishRoundAllowSet(outer, []string{"terminal"}, "capacity:quiet") inner := WithRoundAllowSet(outer) if _, _, ok := roundAllowSetFrom(inner); ok { t.Fatal("nested run must not inherit the parent's published allow set") } if names, source, ok := roundAllowSetFrom(outer); !ok || len(names) != 1 || source != "capacity:quiet" { t.Fatalf("outer allow set = %v/%q/%v, want it untouched", names, source, ok) } if WithRoundAllowSet(nil) != nil { t.Fatal("WithRoundAllowSet(nil) must stay nil") } }