Files
HomeAgent/internal/agent/io/toolblocks_test.go
JianFeeeee 25f5c53086 feat(io): 多模态块改 per-call 归档,为并行执行铺路(阶段 2b)
问题:ConsumeToolBlocks 此前是 **IOManager 级单队列**(取走即清空),
没有 call_id 维度。并行化后同批多个工具各自注入媒体时,后执行的
Consume 会**抢走**前一个的块 ⇒ 媒体挂到错误的 tool 消息上。而
task.go 里「媒体必须走 user message 且紧跟自己的 toolMsg」那条结论
是三轮实测才定下来的,并行会直接破坏它。多模态插件在 3 处调
SetToolBlocks(multimodal/plugin.go:136,246,320),是真实使用面。

改动:
· 字段 toolPendingBlocks: []interface{} → map[string][]interface{}
· 新增 SetToolBlocksFor / ConsumeToolBlocksFor / ClearToolBlocks(按 call_id)
· 保留 SetToolBlocks / ConsumeToolBlocks 作兼容入口(走 callID=""),
  存量调用方与串行单工具场景不受影响;其注释写明并行下该路径不可靠

判据(toolblocks_test.go,4 条):
· 两个 call 各自注入、**交叉顺序**并发取回,各归其主
· 取走即消费(二次取回为空)
· 未知 callID 返回空且**不影响他人**的块
· 32 路并发零串味

变异说明:本阶段是从「单槽」直接改为 per-call,旧实现在这四条判据下
(per-call API 不存在)无法编译通过,等价于判据先失败;per-call 版
再经 `go test -race` 验证无数据竞争。回归:internal/agent/io 全绿。
2026-09-27 11:06:41 +08:00

119 lines
3.7 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 io
import (
"sync"
"testing"
)
// 阶段 2b:`ConsumeToolBlocks` 从 **IOManager 级单队列** 改为 **per-call**。
//
// 现状(channel.go:1010-1021):SetToolBlocks 写入一个共享的
// toolPendingBlocks,ConsumeToolBlocks 取走并清空。它是**全局单槽**,
// 没有 call_id 维度。
//
// 并行化的直接后果:同批多个工具各自注入媒体时,后执行的
// ConsumeToolBlocks 会**抢走**前一个的块 ⇒ 媒体挂到错误的 tool 消息上。
// 而多模态插件在 3 处调用 SetToolBlocks(multimodal/plugin.go:136,246,320),
// 这是真实使用面。
//
// 这条判据钉死「谁注入的归谁」——当前实现必然失败。
// 并发:两个工具各自注入媒体,各自取回,要求**互不串味**。
//
// 不用共享 map 收集结果:goroutine 内写 map 需要额外同步,而按
// 「各 goroutine 只写自己的下标」写切片即可,无需任何锁。
func TestConsumeToolBlocks_PerCallIsolation(t *testing.T) {
m := NewIOManager()
// 模拟两个工具按并行序注入:alpha 先注入,beta 后注入,
// 但取回顺序不定(调度决定,这正是并行下的真实情况)。
m.SetToolBlocksFor("call_alpha", []interface{}{"alpha-block"})
m.SetToolBlocksFor("call_beta", []interface{}{"beta-block"})
var alpha, beta []interface{}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
alpha = m.ConsumeToolBlocksFor("call_beta")
}()
go func() {
defer wg.Done()
beta = m.ConsumeToolBlocksFor("call_alpha")
}()
wg.Wait()
// alpha 的槽里装的应是 beta 抢到的结果?不——上面故意交叉,
// 目的是让两个 goroutine 真正并发取回;此处只断言**各自取到的内容对得上**。
if len(alpha) != 1 || alpha[0] != "beta-block" {
t.Errorf("取 call_beta 的 goroutine 拿到 %#v,期望 [beta-block]", alpha[0])
}
if len(beta) != 1 || beta[0] != "alpha-block" {
t.Errorf("取 call_alpha 的 goroutine 拿到 %#v,期望 [alpha-block]", beta[0])
}
}
// 取走是**消费**语义:同一 call_id 取第二次必须为空。
func TestConsumeToolBlocksFor_IsConsuming(t *testing.T) {
m := NewIOManager()
m.SetToolBlocksFor("c1", []interface{}{"x"})
if b := m.ConsumeToolBlocksFor("c1"); len(b) != 1 {
t.Fatalf("首次取回应得 1 块,实际 %#v", b)
}
if b := m.ConsumeToolBlocksFor("c1"); len(b) != 0 {
t.Errorf("二次取回应为空(消费语义),实际 %#v", b)
}
}
// 未知 call_id 取回必须为空,且**不得**影响他人的块。
func TestConsumeToolBlocksFor_UnknownCallIsEmpty(t *testing.T) {
m := NewIOManager()
m.SetToolBlocksFor("c1", []interface{}{"mine"})
if b := m.ConsumeToolBlocksFor("no_such_call"); len(b) != 0 {
t.Errorf("未知 call 应返回空,实际 %#v", b)
}
if b := m.ConsumeToolBlocksFor("c1"); len(b) != 1 {
t.Errorf("取未知 call 误伤了 c1 的块: %#v", b)
}
}
// 并发压力:N 个 call 各自注入并取回,要求**零串味**。
// 单槽实现在此必然大面积失败——这正是判据的价值。
func TestConsumeToolBlocksFor_ConcurrentNoCrossTalk(t *testing.T) {
m := NewIOManager()
const n = 32
var wg sync.WaitGroup
errs := make(chan string, n)
for i := 0; i < n; i++ {
id := string(rune('A' + i%26))
want := "block-" + string(rune('0'+i%10)) + "-" + id
wg.Add(1)
go func() {
defer wg.Done()
m.SetToolBlocksFor(id, []interface{}{want})
b := m.ConsumeToolBlocksFor(id)
if len(b) != 1 || b[0] != want {
errs <- "call " + id + " 期望 [" + want + "],实际 " + toStr(b)
}
}()
}
wg.Wait()
close(errs)
for e := range errs {
t.Error(e)
}
}
func toStr(b []interface{}) string {
s := "["
for i, v := range b {
if i > 0 {
s += " "
}
s += v.(string)
}
return s + "]"
}