Files
homeagent-sdk/sdk/stress_test.go
JianFeeeee ce5bff9275 feat(sdk): 多模态贯通插件边界——媒体字段、媒体注入接口与并发修复
记忆系统在核心 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放:
插件把 Triple / Doc 交进来,媒体一律无处安放,且**不报错**。本版补上公开接口
侧缺失的表达能力。

## 一、类型与接口(全部新增,无签名变更)

- `Triple` += `SentenceText`、`MediaDigests`
- `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment`
- `TextEvent` += `Attachments`
- `DocMemoryAPI` += `InsertWithMedia`
- `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia`
- `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有,
  插件只能自己去拿 injector)

`MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(内核按字节
去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索
可能命中几十份媒体,把字节全塞回来会撑爆跨进程消息。

媒体注入为什么不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,
且媒体要等**下一条** tool message 才到模型手上。插件主动发起一轮带媒体的对话、
以及中断注入,需要各自的签名,且媒体在**本轮**就随消息发出。

`Triple.MediaDigests` 非空而 `SentenceText` 为空时,内核会用媒体标记本身充当句子
——媒体引用挂在句子上,没有句子就无处挂起。插件只需填 digest,标记由内核拼:
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。

## 二、修掉两处并发竞态

`sdk/stress_test.go` 的 `-race` 实测报 11 处 DATA RACE,收敛到两个字段:

1. **`PluginSDK` 的 API 字段无锁**。写方是内核(加载/重载插件时依次注入
   injector、memory、doc、llm…),读方是插件在 `Start()` 里起的后台 goroutine
   ——轮询、监听、定时器都要拿 injector 往管道注消息。生产表现是插件重载瞬间
   偶发崩溃:读到半个接口值就 nil 解引用。
2. **`autoRestart` 标志无锁**。`SetAutoRestart` 的文档用法本身就是「外部连接建好
   后再决定能否自动重启」,而连接建立通常在后台 goroutine;内核 registry 在另一个
   goroutine 读 `AutoRestart()` 决定崩溃后重启策略。这对读写天然跨 goroutine。

加 `apiMu sync.RWMutex`。关键约定写进注释:**只在持锁期间取字段值,取完立刻
释放再调用**。持锁调用会把 `InjectInputSync`(阻塞到 agent 回复,可达数分钟)
与 `SetIOInjector` 串到一起,让插件重载卡死。

## 三、压测(sdk/stress_test.go,13 例)

SDK 是被多个 goroutine 同时使用的共享对象,单线程单测全绿不代表并发路径成立。
断言的是不变量而非吞吐:

- 媒体注入高并发不丢不串——每次调用带唯一 tag,逐条校验文本与图片 URL 配对。
  「不串」是重点:若实现里出现任何共享中间状态(把 blocks 暂存到字段再读出),
  高并发下会出现 A 的文本配 B 的图,而两者单独看都「成功」了;
- injector 热替换(含替换成 nil,即内核卸载 API 的真实状态);
- stop / onRemove handler 恰好一次——契约是「执行后清空,幂等」,执行两次的后果
  从重复写文件到 close 已关闭 channel 直接 panic;
- `StageContext` 并发读改写无 lost update(媒体链路让 Extra 成为新热点,
  而 map 并发写在 Go 里是直接 fatal,recover 接不住);
- `OwnTools` scope 不跨插件泄漏;
- 媒体类型 JSON 往返字节级一致(9 种长度,含 0/1/2/3 与 base64 分组边界)
  ——`[]byte` 在 JSON 里是 base64,往返不一致意味着图片静默损坏,
  要到 CAS 校验 digest 时才发现,那时已无从追查;
- `omitempty` 真的生效(读路径不能出现 `"data"` 键);
- nil 依赖全部静默降级不 panic。

## 四、工具链同步

- `proc_main.go.tmpl`:`procIO` 三个媒体方法、`procDocMemory.InsertWithMedia`。
  模板不跟上的后果是**每个外部插件都编不过**(接口未实现),是硬失败;
- `proc_runtime_test.go`:方法清单补 `io.injectMedia*` 与 `doc.insertWithMedia`。
  漏接线时插件调 `InjectInputMedia` 会静默无效果——模板不发这个 RPC,内核也就
  收不到,两边都不报错;
- `yaegi/mocksdk`:与公开 SDK 对齐。它此前漂移严重且**没有任何代码对着它编译**,
  所以漂移不会被编译器抓到:`Triple` 用的是 `Predicate`,而公开 SDK 一直叫
  `Relation` —— 插件在 yaegi 调试期写 `Relation:` 报未知字段,写 `Predicate:` 则
  编成 plugin.bin 时报错,两边都不对。
- README 中英双语补媒体接口文档与用法示例。

## 兼容性

存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,不调就不
受影响。17 个 example 插件源码零改动通过类型检查;用 SDK 0.9.2 编的旧 plugin.bin
在新内核上直接建链通过(握手校验的是 ProtocolVersion=1,不是 SDK 版本)。

媒体接口需要核心 1.1.1+(更早的核心没有对应 RPC,调用返回 unknown method)。
`CoreVersion` 保持 1.0.0:它是「SDK 能在其上运行」的下限,媒体是可选能力。
2026-09-06 10:03:36 +08:00

726 lines
22 KiB
Go
Raw 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 sdk
import (
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
)
// SDK 公开接口的并发压力测试1.1.0 媒体接口上线后新增)。
//
// 为什么这一层需要压测SDK 是**被多个 goroutine 同时使用的共享对象**。
// 一个插件的典型形态是 Start() 里起若干后台 goroutine轮询、监听、定时器
// 它们各自持同一个 *PluginSDK 往里注入消息;内核侧同时还有 stage 扇出、
// 工具调用、以及读 AutoRestart() 决定崩溃后是否重启。
// 单线程单测全绿不代表这些并发路径成立。
//
// 关注点不是吞吐数字,而是不变量:
// 1. 注入调用不丢、不串(媒体块必须与文本配对,不能张冠李戴)
// 2. 状态字段的读写不产生数据竞争(-race 下必须干净)
// 3. handler 注册/执行在并发下"恰好一次"
// 4. 跨进程 JSON 序列化对新媒体类型必须字节级往返一致
//
// 媒体接口尤其需要 3 与 4媒体块要经 JSON 过子进程边界,
// 而 []byte 在 JSON 里是 base64往返不一致的后果是图片静默损坏。
// ---------- 测试替身 ----------
// recordingInjector 记录每一次注入调用,用于验证"不丢不串"。
type recordingInjector struct {
mu sync.Mutex
calls []injectCall
// 计数用原子量:并发路径上只增不减,可在不持锁时安全读。
nText, nMedia, nInterrupt, nSync atomic.Int64
}
type injectCall struct {
kind string // text / media / interruptMedia / sync ...
source string
channel string
text string
blocks []ContentBlock
}
func (r *recordingInjector) record(c injectCall) {
r.mu.Lock()
r.calls = append(r.calls, c)
r.mu.Unlock()
}
func (r *recordingInjector) InjectInterruptText(s, c, t string) {
r.nInterrupt.Add(1)
r.record(injectCall{kind: "interruptText", source: s, channel: c, text: t})
}
func (r *recordingInjector) InjectText(s, c, t string) {
r.nText.Add(1)
r.record(injectCall{kind: "text", source: s, channel: c, text: t})
}
func (r *recordingInjector) InjectTextNoMemory(s, c, t string) {
r.nText.Add(1)
r.record(injectCall{kind: "textNoMem", source: s, channel: c, text: t})
}
func (r *recordingInjector) InjectInputSync(s, c, t string) string {
r.nSync.Add(1)
r.record(injectCall{kind: "sync", source: s, channel: c, text: t})
return "reply:" + t
}
func (r *recordingInjector) SetToolBlocks(blocks []ContentBlock) {
r.record(injectCall{kind: "toolBlocks", blocks: blocks})
}
func (r *recordingInjector) InjectInputMedia(s, c, t string, b []ContentBlock) {
r.nMedia.Add(1)
r.record(injectCall{kind: "media", source: s, channel: c, text: t, blocks: b})
}
func (r *recordingInjector) InjectInputMediaSync(s, c, t string, b []ContentBlock) string {
r.nMedia.Add(1)
r.nSync.Add(1)
r.record(injectCall{kind: "mediaSync", source: s, channel: c, text: t, blocks: b})
return "reply:" + t
}
func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBlock) {
r.nMedia.Add(1)
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
}
func (r *recordingInjector) snapshot() []injectCall {
r.mu.Lock()
defer r.mu.Unlock()
return append([]injectCall{}, r.calls...)
}
var _ IOInjector = (*recordingInjector)(nil)
// imageBlock 构造一个带可识别 URL 的图片块。
func imageBlock(tag string) ContentBlock {
return ContentBlock{
Type: "image_url",
ImageURL: &ImageURL{URL: "data:image/png;base64," + tag, Detail: "auto"},
}
}
// ---------- 1. 媒体注入并发不丢不串 ----------
// 三个媒体注入方法在高并发下必须:调用数精确、且每次调用的 text 与 blocks 配对不错。
//
// "不串"是这里的关键断言。注入是插件里最容易被后台 goroutine 并发调用的入口,
// 若实现里出现任何共享中间状态(比如把 blocks 暂存到 SDK 字段再读出),
// 高并发下就会出现 A 的文本配上 B 的图——而两者单独看都"成功"了,不报错。
func TestStress_MediaInjectionConcurrentNoCrossTalk(t *testing.T) {
const workers, perWorker = 32, 200
inj := &recordingInjector{}
s := &PluginSDK{name: "stress"}
s.SetIOInjector(inj)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < perWorker; i++ {
// tag 唯一标识这次调用,文本与图片 URL 里都带上它。
tag := fmt.Sprintf("w%d-i%d", w, i)
switch i % 3 {
case 0:
s.InjectInputMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
case 1:
if got := s.InjectInputMediaSync("src", "ch", tag, []ContentBlock{imageBlock(tag)}); got != "reply:"+tag {
t.Errorf("同步注入回复错位: got %q want %q", got, "reply:"+tag)
}
default:
s.InjectInterruptMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
}
}
}(w)
}
wg.Wait()
total := int64(workers * perWorker)
if got := inj.nMedia.Load(); got != total {
t.Fatalf("媒体注入调用数 = %d期望 %d有调用丢失", got, total)
}
// 逐条校验文本与媒体块配对URL 必须含该次调用自己的 tag。
seen := map[string]bool{}
for _, c := range inj.snapshot() {
if len(c.blocks) == 0 {
continue
}
if c.blocks[0].ImageURL == nil {
t.Fatalf("媒体块 ImageURL 丢失: %+v", c.blocks[0])
}
if !strings.HasSuffix(c.blocks[0].ImageURL.URL, c.text) {
t.Fatalf("文本与媒体块错位: text=%q url=%q", c.text, c.blocks[0].ImageURL.URL)
}
if seen[c.text] {
t.Fatalf("同一次调用被记录两次: %s", c.text)
}
seen[c.text] = true
}
if len(seen) != int(total) {
t.Fatalf("去重后调用数 = %d期望 %d", len(seen), total)
}
}
// ---------- 2. 注入期间热替换 injector ----------
// 内核在插件运行期间可能重新注入 API重载、恢复、子进程重连握手
// 此时插件的后台 goroutine 仍在注入。这条路径若无同步就是对 s.io 的数据竞争,
// 在 -race 下会被抓出;生产表现是偶发 nil 解引用崩溃。
func TestStress_InjectorSwapDuringInjection(t *testing.T) {
s := &PluginSDK{name: "stress"}
s.SetIOInjector(&recordingInjector{})
stop := make(chan struct{})
var injectors, swapper sync.WaitGroup
// 注入方:持续打直到 stop
for w := 0; w < 8; w++ {
injectors.Add(1)
go func() {
defer injectors.Done()
for {
select {
case <-stop:
return
default:
s.InjectInputMedia("src", "ch", "x", []ContentBlock{imageBlock("x")})
s.InjectText("src", "ch", "y")
}
}
}()
}
// 替换方:反复换 injector含换成 nil——内核卸载 API 时的真实状态)
swapper.Add(1)
go func() {
defer swapper.Done()
for i := 0; i < 500; i++ {
if i%7 == 0 {
s.SetIOInjector(nil)
} else {
s.SetIOInjector(&recordingInjector{})
}
}
}()
// 先等替换跑完,再告知注入方退出。
// 顺序写反了就是死锁:注入方只依 close(stop) 退出。
swapper.Wait()
close(stop)
injectors.Wait()
// 断言就是「没崩、-race 没报」。nil injector 时必须静默跳过而非 panic。
}
// ---------- 3. autoRestart 标志的并发读写 ----------
// SetAutoRestart 的文档用途是"插件有无法恢复的状态(如外部连接)时设为 false"——
// 而连接建立本身通常是异步的,所以这个写入天然发生在后台 goroutine。
// 内核侧 registry 在另一个 goroutine 读 AutoRestart() 决定崩溃后是否重启。
// 这是一对跨 goroutine 的读写,必须同步。
func TestStress_AutoRestartFlagConcurrent(t *testing.T) {
s := &PluginSDK{name: "stress", autoRestart: true}
var wg sync.WaitGroup
for w := 0; w < 16; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < 500; i++ {
s.SetAutoRestart(i%2 == 0)
}
}(w)
}
// 读方模拟内核 registry
for r := 0; r < 8; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 500; i++ {
_ = s.AutoRestart()
}
}()
}
wg.Wait()
}
// ---------- 4. stop / onRemove handler 的"恰好一次" ----------
// RunStopHandlers 的契约是"执行后清空,幂等"。内核在停止插件时可能并发触发
// (超时强杀与正常 Stop 竞争handler 里往往是关连接、落盘——
// 执行两次的后果从"重复写文件"到"close 已关闭的 channel 直接 panic"。
func TestStress_StopHandlersExactlyOnce(t *testing.T) {
const n = 300
s := &PluginSDK{name: "stress"}
var counters [n]atomic.Int64
for i := 0; i < n; i++ {
i := i
s.RegisterStopHandler(func() { counters[i].Add(1) })
}
var wg sync.WaitGroup
for w := 0; w < 16; w++ {
wg.Add(1)
go func() {
defer wg.Done()
s.RunStopHandlers()
}()
}
wg.Wait()
for i := 0; i < n; i++ {
if got := counters[i].Load(); got != 1 {
t.Fatalf("handler %d 执行 %d 次,期望恰好 1 次", i, got)
}
}
}
// 注册与执行并发:已注册的 handler 一次都不能多跑,未跑到的也不能被丢。
// 断言用"每个 handler 的执行次数 <= 1"而非"总数相等"——
// 与 RunStopHandlers 竞争的注册可能落在快照之后,那属于合法的未执行。
func TestStress_StopHandlersRegisterWhileRunning(t *testing.T) {
s := &PluginSDK{name: "stress"}
const n = 500
var counters [n]atomic.Int64
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < n; i++ {
i := i
s.RegisterStopHandler(func() { counters[i].Add(1) })
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
s.RunStopHandlers()
}
}()
wg.Wait()
s.RunStopHandlers() // 收尾:把剩下的都跑掉
for i := 0; i < n; i++ {
if got := counters[i].Load(); got > 1 {
t.Fatalf("handler %d 被执行 %d 次(重复执行)", i, got)
}
}
}
func TestStress_OnRemoveHandlersExactlyOnce(t *testing.T) {
const n = 200
s := &PluginSDK{name: "stress"}
var counters [n]atomic.Int64
for i := 0; i < n; i++ {
i := i
s.RegisterOnRemoveHandler(func() { counters[i].Add(1) })
}
var wg sync.WaitGroup
for w := 0; w < 12; w++ {
wg.Add(1)
go func() {
defer wg.Done()
s.RunOnRemoveHandlers()
}()
}
wg.Wait()
for i := 0; i < n; i++ {
if got := counters[i].Load(); got != 1 {
t.Fatalf("onRemove handler %d 执行 %d 次,期望恰好 1 次", i, got)
}
}
}
// ---------- 5. StageContext 并发读改写 ----------
// StageContext 是全部 stage handler 共享的可变状态,字段全导出、靠调用方自觉
// 持 Lock/RLock。媒体链路让 Extra 成为新热点media_blocks 挂在这里),
// 而 map 的并发写在 Go 里是直接 fatalrecover 都接不住。
//
// 这条测试锁定的不变量:按约定持锁的并发读改写不丢更新、不 fatal。
func TestStress_StageContextConcurrentExtraAndFinalText(t *testing.T) {
ctx := &StageContext{Extra: map[string]interface{}{}}
const workers, rounds = 16, 200
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
// 写:模拟插件往 Extra 塞媒体块并追加文本(读-改-写)
ctx.Lock()
ctx.Extra[fmt.Sprintf("k%d-%d", w, i)] = []ContentBlock{imageBlock("x")}
ctx.FinalText += "."
ctx.Unlock()
// 读:模拟另一个 handler 检查是否已被响应
_ = ctx.IsResponded()
ctx.RLock()
_ = len(ctx.Extra)
ctx.RUnlock()
}
}(w)
}
wg.Wait()
ctx.RLock()
defer ctx.RUnlock()
if len(ctx.Extra) != workers*rounds {
t.Fatalf("Extra 键数 = %d期望 %d出现 lost update", len(ctx.Extra), workers*rounds)
}
if len(ctx.FinalText) != workers*rounds {
t.Fatalf("FinalText 长度 = %d期望 %d出现 lost update", len(ctx.FinalText), workers*rounds)
}
}
// ---------- 6. OwnTools scope 包装器的并发正确性 ----------
// StageScopeOwnTools 的包装闭环里要读 ctx.ToolCalls 判断归属。
// 并发下若判断与执行之间状态被改写,就会出现"别人的工具触发了我的 handler"——
// 后果是插件对不属于自己的工具结果动手,且没有任何错误。
func TestStress_OwnToolsScopeNoCrossPluginLeak(t *testing.T) {
var registered StageHandler
s := &PluginSDK{
name: "mine",
regStage: func(stage Stage, h StageHandler) { registered = h },
}
var fired atomic.Int64
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
fired.Add(1)
ctx.RLock()
defer ctx.RUnlock()
// 触发了就必须确实是自己的工具
if len(ctx.ToolCalls) == 0 || ctx.ToolCalls[0].Plugin != "mine" {
t.Errorf("handler 被别的插件的工具触发: %+v", ctx.ToolCalls)
}
return nil
}, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler 未注册")
}
const workers, rounds = 16, 100
var wg sync.WaitGroup
var mineCount atomic.Int64
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
// 每个 goroutine 用自己的 ctx——真实内核里 stage 扇出共享同一个
// ctx但那部分的并发由内核 host 仲裁;这里验证包装器本身。
owner := "other"
if (w+i)%2 == 0 {
owner = "mine"
mineCount.Add(1)
}
ctx := &StageContext{Extra: map[string]interface{}{}}
ctx.ToolCalls = []ToolCall{{Plugin: owner, Name: "t"}}
if err := registered(ctx); err != nil {
t.Errorf("handler 返回错误: %v", err)
}
}
}(w)
}
wg.Wait()
if got, want := fired.Load(), mineCount.Load(); got != want {
t.Fatalf("handler 触发 %d 次,期望 %d 次(漏触发或跨插件触发)", got, want)
}
}
// ---------- 7. 媒体类型的 JSON 往返(跨进程边界的真实形态) ----------
// 媒体块与附件要经 JSON 过子进程边界。[]byte 在 JSON 里是 base64
// 往返不一致的后果是图片字节静默损坏——落进 CAS 后 digest 校验才会发现,
// 而那时已经无从追查是谁改坏的。
func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
// 覆盖真实会遇到的边界:空、单字节、含 0x00、全 0xFF、超过 base64 分组边界的长度
sizes := []int{0, 1, 2, 3, 255, 256, 1023, 4096, 65537}
for _, n := range sizes {
data := make([]byte, n)
for i := range data {
data[i] = byte(i * 7 % 256)
}
att := MediaAttachment{
Digest: strings.Repeat("a", 64),
MIME: "image/png",
Data: data,
Name: "图片-名字 with space & 符号.png",
Description: "一张紫蓝红三色带图,含 emoji 🎨 与换行\n第二行",
}
b, err := json.Marshal(att)
if err != nil {
t.Fatalf("size=%d marshal: %v", n, err)
}
var back MediaAttachment
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("size=%d unmarshal: %v", n, err)
}
if len(back.Data) != n {
t.Fatalf("size=%d 往返后长度 = %d", n, len(back.Data))
}
for i := range data {
if back.Data[i] != data[i] {
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
}
}
if back.Name != att.Name || back.Description != att.Description || back.MIME != att.MIME || back.Digest != att.Digest {
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
}
}
}
// omitempty 必须真的生效:读路径上内核不回 Data若序列化仍产出 "data":null
// 之类的键,跨进程消息会凭空变大,且插件侧无法区分"没有字节"与"空字节"。
func TestStress_MediaTypesOmitEmpty(t *testing.T) {
cases := []struct {
name string
v interface{}
absent []string
present []string
}{
{
name: "Triple 无媒体",
v: Triple{Subject: "甲方", Relation: "签署", Object: "合同"},
absent: []string{"media_digests", "sentence_text", "confidence", "subject_type", "object_type"},
present: []string{"subject", "relation", "object"},
},
{
name: "Triple 带媒体",
v: Triple{Subject: "甲方", Relation: "包含", Object: "图", MediaDigests: []string{"abc12345"}, SentenceText: "句子"},
absent: []string{"confidence"},
present: []string{"media_digests", "sentence_text"},
},
{
name: "Doc 读路径无字节",
v: Doc{ID: "d1", Title: "标题", Content: "正文", Attachments: []MediaAttachment{{Digest: "abc12345", MIME: "image/png"}}},
absent: []string{"\"data\"", "media_digests", "score"},
present: []string{"attachments", "digest", "mime"},
},
{
name: "TextEvent 无附件",
v: TextEvent{Role: "user", Content: "hi"},
absent: []string{"attachments", "channel"},
present: []string{"role", "content"},
},
{
name: "ContentBlock 纯文本",
v: ContentBlock{Type: "text", Text: "hi"},
absent: []string{"image_url", "audio_url"},
present: []string{"type", "text"},
},
{
name: "ContentBlock 图片",
v: imageBlock("AAA"),
absent: []string{"audio_url", "\"text\""},
present: []string{"image_url", "detail"},
},
}
for _, c := range cases {
b, err := json.Marshal(c.v)
if err != nil {
t.Fatalf("%s marshal: %v", c.name, err)
}
s := string(b)
for _, k := range c.absent {
if strings.Contains(s, k) {
t.Errorf("%s: 不该出现的键 %s —— %s", c.name, k, s)
}
}
for _, k := range c.present {
if !strings.Contains(s, k) {
t.Errorf("%s: 缺少键 %s —— %s", c.name, k, s)
}
}
}
}
// 媒体块在并发序列化下必须各自独立ImageURL/AudioURL 是指针,
// 若某处复用同一个指针再改写,序列化结果会互相污染。
func TestStress_ContentBlockConcurrentMarshal(t *testing.T) {
const workers, rounds = 16, 300
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
tag := fmt.Sprintf("w%d-i%d", w, i)
blocks := []ContentBlock{
{Type: "text", Text: tag},
imageBlock(tag),
{Type: "audio_url", AudioURL: &AudioURL{URL: "data:audio/wav;base64," + tag}},
}
b, err := json.Marshal(blocks)
if err != nil {
t.Errorf("marshal: %v", err)
return
}
var back []ContentBlock
if err := json.Unmarshal(b, &back); err != nil {
t.Errorf("unmarshal: %v", err)
return
}
if len(back) != 3 {
t.Errorf("块数 = %d", len(back))
return
}
if back[0].ImageURL != nil || back[0].AudioURL != nil {
t.Errorf("文本块被填了媒体指针: %+v", back[0])
}
if back[1].ImageURL == nil || !strings.HasSuffix(back[1].ImageURL.URL, tag) {
t.Errorf("图片块 URL 错位: %+v", back[1].ImageURL)
}
if back[1].AudioURL != nil {
t.Errorf("图片块被填了音频指针")
}
if back[2].AudioURL == nil || !strings.HasSuffix(back[2].AudioURL.URL, tag) {
t.Errorf("音频块 URL 错位: %+v", back[2].AudioURL)
}
}
}(w)
}
wg.Wait()
}
// ---------- 8. 注册面的并发 ----------
// 插件在 Start() 里起多个 goroutine 分别注册工具是常见写法。
// def.Plugin 的默认填充若不是每次调用独立的,就会出现工具归属错乱——
// 表现是 OwnTools scope 失效、WebUI 里工具挂在别的插件名下。
func TestStress_RegisterToolConcurrentPluginDefaulting(t *testing.T) {
var mu sync.Mutex
got := map[string]string{} // toolName -> def.Plugin
s := &PluginSDK{
name: "mine",
regTool: func(name string, def ToolDef, h ToolHandler) error {
mu.Lock()
got[name] = def.Plugin
mu.Unlock()
return nil
},
}
const workers, perWorker = 16, 100
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < perWorker; i++ {
name := fmt.Sprintf("tool_w%d_i%d", w, i)
def := ToolDef{Description: "d", Parameters: map[string]interface{}{}}
// 一半显式指定归属,一半靠 SDK 填默认值
if i%2 == 0 {
def.Plugin = "explicit"
}
if err := s.RegisterTool(name, def, func(map[string]interface{}) (interface{}, error) {
return nil, nil
}); err != nil {
t.Errorf("RegisterTool: %v", err)
}
}
}(w)
}
wg.Wait()
if len(got) != workers*perWorker {
t.Fatalf("注册工具数 = %d期望 %d", len(got), workers*perWorker)
}
for name, owner := range got {
want := "mine"
if isEvenSuffix(name) {
want = "explicit"
}
if owner != want {
t.Fatalf("工具 %s 归属 = %q期望 %q", name, owner, want)
}
}
}
// isEvenSuffix 判断 tool_wX_iY 里的 Y 是否为偶数。
func isEvenSuffix(name string) bool {
idx := strings.LastIndex(name, "_i")
if idx < 0 {
return false
}
n := 0
if _, err := fmt.Sscanf(name[idx+2:], "%d", &n); err != nil {
return false
}
return n%2 == 0
}
// nil 依赖下所有便捷方法必须静默降级而非 panic。
//
// 这是"媒体存储可关闭"在 SDK 层的对应物:内核未注入某个 API 时
// (精简部署、插件权限不足、子进程握手尚未完成),插件的调用不该崩。
func TestStress_NilDependenciesDegradeSilently(t *testing.T) {
s := &PluginSDK{name: "bare"}
const workers = 16
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
s.InjectText("s", "c", "t")
s.InjectTextNoMemory("s", "c", "t")
s.InjectInterruptText("s", "c", "t")
if got := s.InjectInputSync("s", "c", "t"); got != "" {
t.Errorf("无 injector 时同步注入应返回空串got %q", got)
}
s.InjectInputMedia("s", "c", "t", []ContentBlock{imageBlock("x")})
if got := s.InjectInputMediaSync("s", "c", "t", nil); got != "" {
t.Errorf("无 injector 时媒体同步注入应返回空串got %q", got)
}
s.InjectInterruptMedia("s", "c", "t", nil)
// getter 全部应返回 nil 而非 panic
_ = s.Memory()
_ = s.TextMemory()
_ = s.DocMemory()
_ = s.Knowledge()
_ = s.LLM()
_ = s.Social()
_ = s.Events()
_ = s.PluginMgr()
_ = s.Settings()
// 注册面无 registrar 时应返回 nil error
if err := s.RegisterTool("t", ToolDef{}, nil); err != nil {
t.Errorf("无 registrar 时 RegisterTool 应返回 nilgot %v", err)
}
if err := s.RegisterPluginAPI("a"); err != nil {
t.Errorf("无 registrar 时 RegisterPluginAPI 应返回 nilgot %v", err)
}
s.RegisterStage(StageOnInput, func(*StageContext) error { return nil })
}
}()
}
wg.Wait()
}