Files
Cyrene_v2/internal/services/channelbridge/inbound_documents_test.go
T
AskaEth 7d9b2132bb test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)
四处测试健康债务,只改 *_test.go,不动生产代码、不降并发:

- channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问
  (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before;
  负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages()
  返回副本;t.Parallel 与并发度不变。
- plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait
  按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把
  「机器快慢」从断言里去掉,死锁仍以断言失败暴露。
- backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界
  留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。
  另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进
  Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。
- memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms
  超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
2026-10-24 22:24:14 +08:00

397 lines
14 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 channelbridge
// 本文件锁定 **IM 入站附件摄取**(design/24 §3 / §4.4;切片 M1):
//
// - 文件类媒体 → 共享文档存储落库 → 装配期抽取 → **模型可见视图里出现清单行与
// 正文**(真 store + 真抽取器 + 真循环 + 假模型,端到端);
// - 不支持的格式**不落库**,但模型收到一行诚实提示、日志留一条带来源的记录,
// 且日志**绝不记正文**;
// - 渠道只给远程引用(无内联字节)时同样诚实,不静默;
// - 摄取在**闸门之后**:被拉黑/被隔离的消息绝不在盘上留下文件,其附件也只进
// 隔离会话,不镜像到主人主会话;
// - 只有附件的消息(无文本)不再被判成「没有内容」而丢弃;
// - 图片类媒体不进文档摄取(不落库、不挂文档部件),走 inbound_images.go 的 data: URI
// 图片部件进视觉链路。
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// stubModelAdapter 是最小的 model.Adapter:记下每次补全请求的**模型可见视图**
// (即装配期 resolveDocuments 替换之后的形态),并回一条固定文本。
type stubModelAdapter struct {
mu sync.Mutex
seen [][]model.Message
}
func (a *stubModelAdapter) Name() string { return "stub" }
func (a *stubModelAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
a.mu.Lock()
a.seen = append(a.seen, req.Messages)
a.mu.Unlock()
return model.Response{Model: "stub", Content: "读完了", StopReason: "stop"}, nil
}
func (a *stubModelAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return a.Chat(ctx, req, model.Options{})
}
// visibleText 返回模型最后看到的全部消息文本(含系统与用户回合)。
func (a *stubModelAdapter) visibleText() string {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.seen) == 0 {
return ""
}
var b strings.Builder
for _, m := range a.seen[len(a.seen)-1] {
for _, c := range m.Content {
b.WriteString(c.Text)
b.WriteString("\n")
}
}
return b.String()
}
// calls 返回模型被调用的次数(0 = 循环根本没跑)。
func (a *stubModelAdapter) calls() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.seen)
}
// safeBuffer 是并发安全的日志缓冲(桥接器的入站路径可能在 goroutine 里写日志)。
type safeBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *safeBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *safeBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// ingestEnv 是入站附件端到端用例的装配:**真 store + 真抽取器 + 真循环 + 假模型**,
// 因此模型看到的清单行与正文就是生产路径装配出来的那一份。
type ingestEnv struct {
bridge *Bridge
model *stubModelAdapter
adapter *fakeAdapter
sessions *stubSessions
uploads string
logs *safeBuffer
}
// newIngestEnv 装配上述端到端环境。cfg 给出入站附件的落盘配置(未给 UploadsDir 时
// 落到临时目录);cfg 为零值时即「未接线」用例(跳过 SetDocumentStore)。
func newIngestEnv(t *testing.T, cfg documents.StoreConfig, wireStore bool) *ingestEnv {
t.Helper()
dataDir := t.TempDir()
uploads := cfg.UploadsDir
if uploads == "" {
uploads = filepath.Join(dataDir, "uploads", "documents")
}
cfg.UploadsDir = uploads
logs := &safeBuffer{}
log := slog.New(slog.NewTextHandler(logs, nil))
modelAdapter := &stubModelAdapter{}
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil))
if err != nil {
t.Fatalf("构造 agent: %v", err)
}
resolver := documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads, Log: log})
runner := loop.New(loop.Options{
Agent: ag, DocumentResolver: resolver, MaxRounds: 4, Log: log,
})
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
opts := Options{
Log: log, Loop: runner, Sessions: sess, Owner: testOwnerProfile("telegram", "100"),
}
if wireStore {
opts.DocumentStore = cfg
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
platform := &fakeAdapter{name: "telegram"}
b.Add(platform)
return &ingestEnv{
bridge: b, model: modelAdapter, adapter: platform,
sessions: sess, uploads: uploads, logs: logs,
}
}
// fileMessage 构造一条主人私聊消息,附带一个文件类媒体。
func fileMessage(text, name string, data []byte, mime string) adapter.UnifiedMessage {
msg := directMessage()
msg.Content = text
msg.ContentType = adapter.ContentText
if text == "" {
msg.ContentType = adapter.ContentFile
}
msg.Media = []adapter.Media{{
Kind: adapter.MediaFile, Name: name, Size: int64(len(data)), MIMEType: mime, Data: data,
}}
return msg
}
// docDirNames 返回文档落盘目录里的条目名(目录不存在时返回 nil)。
func docDirNames(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
t.Fatalf("读文档目录: %v", err)
}
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name())
}
return out
}
// TestInboundFileMediaIngestedAndVisibleToModel 覆盖验收第 3 条:一条带文件媒体的
// 入站消息 → 落库 → 抽取 → **模型可见视图里出现该附件的清单行与正文**。
func TestInboundFileMediaIngestedAndVisibleToModel(t *testing.T) {
t.Parallel()
env := newIngestEnv(t, documents.StoreConfig{}, true)
body := "第一行:昔涟的测试附件\n第二行:正文必须真的进上下文\n"
msg := fileMessage("看看这个", "notes.txt", []byte(body), "text/plain")
env.bridge.process(context.Background(), msg)
names := docDirNames(t, env.uploads)
if len(names) != 1 {
t.Fatalf("落盘文件数 = %v, want 1(附件必须落库)", names)
}
stored, err := os.ReadFile(filepath.Join(env.uploads, names[0]))
if err != nil {
t.Fatalf("读落盘文件: %v", err)
}
if string(stored) != body {
t.Fatalf("落盘内容 = %q, want %q", stored, body)
}
if !strings.HasSuffix(names[0], ".txt") || len(names[0]) != documents.HashHexLen+len(".txt") {
t.Fatalf("落盘名 = %q, want <32 位十六进制>.txt(内容寻址)", names[0])
}
visible := env.model.visibleText()
if !strings.Contains(visible, "[附件 "+names[0]+" · ") {
t.Fatalf("模型可见视图里没有附件清单行:%q", visible)
}
if !strings.Contains(visible, "第二行:正文必须真的进上下文") {
t.Fatalf("模型可见视图里没有附件正文:%q", visible)
}
if !strings.Contains(visible, "看看这个") {
t.Fatalf("模型可见视图里丢了用户文本:%q", visible)
}
if log := env.logs.String(); !strings.Contains(log, "inbound attachment stored") ||
!strings.Contains(log, "source=services/channelbridge") {
t.Fatalf("落库日志缺少来源或内容:%q", log)
}
}
// TestInboundUnsupportedFileIsHonestAndNotStored 覆盖验收第 4 条:不支持的格式
// 不落库,但模型知道「有附件读不到」,且日志带来源、只记 name/mime/size(绝不记正文)。
func TestInboundUnsupportedFileIsHonestAndNotStored(t *testing.T) {
t.Parallel()
cases := []struct {
label string
name string
mime string
data []byte
}{
{"扩展名不在白名单", "tool.py", "text/x-python", []byte("print('秘密内容')\n")},
{"内容被嗅探否决", "lie.txt", "text/plain", append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 32)...)},
}
for _, tc := range cases {
env := newIngestEnv(t, documents.StoreConfig{}, true)
env.bridge.process(context.Background(), fileMessage("跑一下", tc.name, tc.data, tc.mime))
if names := docDirNames(t, env.uploads); len(names) != 0 {
t.Fatalf("%s: 被拒的附件仍落库 %v", tc.label, names)
}
visible := env.model.visibleText()
if !strings.Contains(visible, tc.name) || !strings.Contains(visible, "无法读取") {
t.Fatalf("%s: 模型可见视图里没有诚实提示行:%q", tc.label, visible)
}
if env.model.calls() == 0 {
t.Fatalf("%s: 回合没有跑到模型", tc.label)
}
log := env.logs.String()
if !strings.Contains(log, "inbound attachment rejected") || !strings.Contains(log, tc.name) {
t.Fatalf("%s: 缺少带来源的拒绝日志:%q", tc.label, log)
}
if strings.Contains(log, "秘密内容") {
t.Fatalf("%s: 日志里出现了附件正文(红线:只记 name/mime/size)", tc.label)
}
}
}
// TestInboundFileMediaBlacklistedNotIngested 覆盖验收第 5 条的一半:被黑白名单拉黑的
// 群/陌生人消息,附件**不落库**、也不驱动循环(摄取在闸门之后)。
func TestInboundFileMediaBlacklistedNotIngested(t *testing.T) {
t.Parallel()
uploads := filepath.Join(t.TempDir(), "uploads", "documents")
chat := &stubChatter{res: &loop.Result{Text: "不该回复"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
var rejected []string
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{},
DocumentStore: documents.StoreConfig{UploadsDir: uploads},
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram", cfg: map[string]any{
"bot_token": "configured", "denyGroups": []string{"-100"},
}}
b.Add(ad)
msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-100", ChannelType: adapter.ChannelGroup,
GroupID: "-100", SenderID: "9", SenderName: "陌生人",
Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "给你个文件",
ContentType: adapter.ContentText,
Media: []adapter.Media{{
Kind: adapter.MediaFile, Name: "stranger.txt", Size: 6, MIMEType: "text/plain", Data: []byte("你好\n"),
}},
}
b.process(context.Background(), msg)
if names := docDirNames(t, uploads); len(names) != 0 {
t.Fatalf("被拉黑的消息把附件落库了:%v", names)
}
if chat.SessionID() != "" || chat.Messages() != nil {
t.Fatalf("被拉黑的消息仍驱动了循环:session=%q", chat.SessionID())
}
if len(rejected) != 1 || rejected[0] != "group_denied" {
t.Fatalf("拒绝原因 = %v, want [group_denied]", rejected)
}
}
// TestInboundGroupFileStaysInIsolatedSession 覆盖验收第 5 条的另一半:第三方(群聊)
// 消息的附件只随该消息进入**隔离会话**,绝不镜像到主人主会话(沿用既有策略测试写法)。
func TestInboundGroupFileStaysInIsolatedSession(t *testing.T) {
t.Parallel()
uploads := filepath.Join(t.TempDir(), "uploads", "documents")
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
DocumentStore: documents.StoreConfig{UploadsDir: uploads},
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 被点名的群消息 → 回复(design/12 §6 策略二),因此会真的走完流水线。
msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
Content: "看下这个", ContentType: adapter.ContentText,
Media: []adapter.Media{{
Kind: adapter.MediaFile, Name: "third.txt", Size: 4, MIMEType: "text/plain", Data: []byte("hi\n"),
}},
}
b.process(context.Background(), msg)
if names := docDirNames(t, uploads); len(names) != 1 {
t.Fatalf("群聊附件落盘文件数 = %v, want 1(第三方附件同样走共享存储)", names)
}
if chat.SessionID() != "sess-g" {
t.Fatalf("循环会话 = %q, want 隔离会话 sess-g", chat.SessionID())
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("隔离会话落库条数 = %d, want 2", len(recs))
}
for i, rec := range recs {
if rec.SessionID != "sess-g" {
t.Fatalf("record[%d] SessionID = %q, want 隔离会话(绝不镜像到主人主会话)", i, rec.SessionID)
}
}
// 用户消息在循环里带着文档部件(图片/文档只进用户回合)。
var docParts int
for _, m := range chat.Messages() {
if m.Role == model.RoleSystem {
continue
}
for _, c := range m.Content {
if c.Type == model.ContentTypeDocument {
docParts++
}
}
}
if docParts != 1 {
t.Fatalf("用户消息里的文档部件数 = %d, want 1", docParts)
}
}
// TestInboundFileOnlyMessageReachesLoop 钉住「只有附件、没有文本」的消息不再被判成
// 没有内容而丢弃,且那条消息**不塞空文本部件**(provider 会拒空文本块)。
func TestInboundFileOnlyMessageReachesLoop(t *testing.T) {
t.Parallel()
env := newIngestEnv(t, documents.StoreConfig{}, true)
msg := fileMessage("", "only.md", []byte("# 只有附件\n正文\n"), "text/markdown")
env.bridge.process(context.Background(), msg)
if env.model.calls() == 0 {
t.Fatal("纯附件消息没有驱动循环")
}
names := docDirNames(t, env.uploads)
if len(names) != 1 {
t.Fatalf("落盘文件数 = %v, want 1", names)
}
visible := env.model.visibleText()
if !strings.Contains(visible, "[附件 "+names[0]+" · ") || !strings.Contains(visible, "# 只有附件") {
t.Fatalf("模型可见视图里没有清单行或正文:%q", visible)
}
env.model.mu.Lock()
defer env.model.mu.Unlock()
if len(env.model.seen) == 0 {
t.Fatal("没有记录到模型请求")
}
for _, m := range env.model.seen[0] {
if m.Role != model.RoleUser {
continue
}
for _, c := range m.Content {
if c.Type == model.ContentTypeText && strings.TrimSpace(c.Text) == "" {
t.Fatalf("纯附件消息里塞了空文本部件:%+v", m.Content)
}
}
}
}