Files
Cyrene_v2/internal/services/gateway/chat_documents_test.go
T
AskaEth 5b2b385524
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 文档多值化 + 抽取结论回写(design/24 S3.5)
- 缺口①:「一回合最多 5 个附件、落库只留 1 个」——document 列语义升级为 ≤5 个引用的**数组**(列名/字段名不变、无需迁移,旧单对象行可读回);落库收集全部引用、转录还原全部部件。

- 缺口②:抽取结论 status/chars/truncated 经 completeTurn 回合后**回写**该回合用户消息的同一列(id 按代次配对,忙时消息挂 pendingMsg.persisted 并在 drain 时以新 gen 重新配对);历史 DTO 改为 **documents: []**(omitempty ⇒ 0 个附件时省略)。

- 防御:编/解码超 5 都报 ErrTooManyDocuments(报错、绝不裁剪);坏列走既有不静默路径(转录 WARN + DTO unreadable 最小引用);>5 上限值被独立用具例钉死。

- 顺手修 S3-B 遗留的函数越界:submitInternal 81 → 79 行(播种块抽成 seedTurnLocked);session_orchestrator.go 484 行。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 12 文件)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿、gateway+history 437 PASS;变异独立复现(只留第一个附件 ⇒ 2 条红,含 HTTP 端到端 want 3 条)。

- 遗留(已登记):chat_documents_test.go 510 行(S3-B 遗留越界,本次未拆);中断/被取代的回合不回写结论;回写后不补发实时帧(status 需重新拉取/回放可见)。
2026-10-04 17:42:16 +08:00

511 lines
20 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 gateway
// 本文件锁定切片 S3 的 B 路(网关侧,design/24 §4.2 / §6.1 / §9.5):
// - `chat.documents` 解析为**用户回合**的文档部件(红线:只允许 RoleUser);
// - 单回合条数上限(too_many_documents)与存储名形态 / 存在性校验
// (document_not_found),且**绝不接受 URL**(不重开 SSRF 面);
// - 历史重建带回文档引用、纯附件回合不被判空(S2 移交的两处「静默吞掉」)。
//
// 断言一律走哨兵错误 + errors.Is / errors.As(机器可读的 code),不按错误文本
// 子串断言 —— 文案会变,契约不变。文档落盘用临时目录,绝不触碰 data/cyrene.db。
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// chatDocRunner 记录每次 Run 看到的完整消息(含文档部件)。流式回合跑在后台
// goroutine 上,因此用互斥锁守住记录。
type chatDocRunner struct {
mu sync.Mutex
calls [][]model.Message
}
func (r *chatDocRunner) Run(_ context.Context, _ string, msgs []model.Message) (*loop.Result, error) {
r.mu.Lock()
r.calls = append(r.calls, msgs)
r.mu.Unlock()
return &loop.Result{Text: "收到了", Steps: 1, StopReason: "stop"}, nil
}
func (r *chatDocRunner) snapshot() [][]model.Message {
r.mu.Lock()
defer r.mu.Unlock()
return append([][]model.Message(nil), r.calls...)
}
// waitCalls 等到至少 n 次 Run 被记录(后台流式回合),超时即失败。
func (r *chatDocRunner) waitCalls(t *testing.T, n int) [][]model.Message {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if calls := r.snapshot(); len(calls) >= n {
return calls
}
time.Sleep(time.Millisecond)
}
t.Fatalf("等待 %d 次回合超时(实际 %d 次)", n, len(r.snapshot()))
return nil
}
// chatDocumentsEnv 打包文档聊天测试所需的依赖:真实 JWT、临时数据目录(文档
// 落盘在它下面)、接线的录制 runner。
type chatDocumentsEnv struct {
srv *Server
ts *httptest.Server
dataDir string
auth *auth.JWTService
runner *chatDocRunner
}
func newChatDocumentsEnv(t *testing.T) *chatDocumentsEnv {
t.Helper()
dataDir := t.TempDir()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
runner := &chatDocRunner{}
srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: runner})
if err != nil {
t.Fatalf("构造网关失败: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
t.Cleanup(srv.Close)
return &chatDocumentsEnv{srv: srv, ts: ts, dataDir: dataDir, auth: jwt, runner: runner}
}
func (e *chatDocumentsEnv) token(t *testing.T) string {
t.Helper()
tok, err := e.auth.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("签发 token 失败: %v", err)
}
return tok
}
// storeDocumentFile 按 S1 的内容寻址规则(sha256 前 32 位 + 扩展名)在
// `<DataDir>/uploads/documents` 下落一份文档,并返回它的存储名。
func (e *chatDocumentsEnv) storeDocumentFile(t *testing.T, content, ext string) string {
t.Helper()
sum := sha256.Sum256([]byte(content))
name := hex.EncodeToString(sum[:])[:documentHashHexLen] + ext
if !validDocumentName(name) {
t.Fatalf("构造的存储名 %q 不是合法形态(与 S1 口径不一致)", name)
}
dir := filepath.Join(e.dataDir, "uploads", documentSubDir)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("创建文档目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatalf("写入文档失败: %v", err)
}
return name
}
// validFormDocumentName 返回一个**形态合法但不存在**的存储名(用于条数上限与
// 存在性两条判定的分辨:先撞上限还是先撞不存在)。
func validFormDocumentName(seed string) string {
sum := sha256.Sum256([]byte(seed))
return hex.EncodeToString(sum[:])[:documentHashHexLen] + ".txt"
}
// chatDocumentsOf 取出这些消息里的全部文档部件。
func chatDocumentsOf(msgs []model.Message) []model.Content {
out := make([]model.Content, 0, len(msgs))
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeDocument {
out = append(out, c)
}
}
}
return out
}
// assertOnlyUserCarriesDocuments 断言文档部件只挂在用户回合上(design/24 §2.3-③
// 的红线:编码器会对非 user 的文档块 fail-loud,因此这一条必须在这里就成立)。
func assertOnlyUserCarriesDocuments(t *testing.T, msgs []model.Message) {
t.Helper()
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeDocument && m.Role != model.RoleUser {
t.Fatalf("文档部件挂在了 %s 回合上(只允许 user)", m.Role)
}
}
}
}
// TestChatDocumentsAttachAsUserParts 钉住主路径:REST chat 带 documents →
// 该回合的用户消息上多出一个文档部件,且它的 name / MIME / URL / 大小全部由
// **服务端**解析(客户端只给了 name)。
func TestChatDocumentsAttachAsUserParts(t *testing.T) {
t.Parallel()
env := newChatDocumentsEnv(t)
const content = "行1\n行2\n"
name := env.storeDocumentFile(t, content, ".txt")
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web",
"text": "读一下这份文件",
// 客户端只给 name;MIME / URL / 大小就算谎报也必须被服务端自己的解析覆盖
// (design/24 §3.2:字节嗅探是唯一裁决)。
"documents": []map[string]any{{
"name": name,
"mime": "text/html",
"url": "https://evil.example/steal.txt",
"size": 999999,
}},
}, env.token(t))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
calls := env.runner.snapshot()
if len(calls) != 1 {
t.Fatalf("驱动了 %d 个回合,want 1", len(calls))
}
msgs := calls[0]
assertOnlyUserCarriesDocuments(t, msgs)
docs := chatDocumentsOf(msgs)
if len(docs) != 1 {
t.Fatalf("文档部件数 = %d, want 1 (msgs=%+v)", len(docs), msgs)
}
got := docs[0]
if got.DocumentName != name {
t.Fatalf("DocumentName = %q, want %q", got.DocumentName, name)
}
if got.DocumentMIME != documentSniffOKMIME {
t.Fatalf("DocumentMIME = %q, want %q(嗅探所得,绝不是客户端声明)", got.DocumentMIME, documentSniffOKMIME)
}
if want := documentFilePrefix + name; got.DocumentURL != want {
t.Fatalf("DocumentURL = %q, want %q(服务端本地引用)", got.DocumentURL, want)
}
if got.DocumentSize != int64(len(content)) {
t.Fatalf("DocumentSize = %d, want %d", got.DocumentSize, len(content))
}
// 文本仍在同一条用户消息上,顺序是「文本在前、附件在后」。
last := msgs[len(msgs)-1]
if last.Role != model.RoleUser || last.Content[0].Type != model.ContentTypeText {
t.Fatalf("末条消息 = %+v, want 用户文本消息在前", last)
}
}
// TestChatDocumentsPureAttachmentTurn 钉住「只带附件」的回合:既不是空回合,
// 也不会塞一个空文本块(空文本块会被 provider 拒)。
func TestChatDocumentsPureAttachmentTurn(t *testing.T) {
t.Parallel()
env := newChatDocumentsEnv(t)
name := env.storeDocumentFile(t, "只有附件", ".md")
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web",
"text": "",
"documents": []chatDocumentRef{{Name: name}},
}, env.token(t))
if resp.StatusCode != http.StatusOK {
t.Fatalf("纯附件回合被拒:status = %d (body=%s)", resp.StatusCode, body)
}
calls := env.runner.snapshot()
if len(calls) != 1 {
t.Fatalf("驱动了 %d 个回合,want 1(纯附件回合是真实回合)", len(calls))
}
msgs := calls[0]
if len(chatDocumentsOf(msgs)) != 1 {
t.Fatalf("文档部件丢失:%+v", msgs)
}
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeText && c.Text == "" {
t.Fatalf("纯附件回合塞了空文本部件:%+v", m)
}
}
}
}
// TestChatDocumentsTooManyRejected 钉住单回合条数上限:恰好 5 条通过,第 6 条
// 起 400 + too_many_documents,且条数判定**先于**任何文件系统访问(6 条全不存在
// 也报条数超限,而不是 document_not_found)。回合一次都不许起。
func TestChatDocumentsTooManyRejected(t *testing.T) {
t.Parallel()
env := newChatDocumentsEnv(t)
refs := make([]chatDocumentRef, 0, maxChatDocuments+1)
for i := 0; i < maxChatDocuments; i++ {
refs = append(refs, chatDocumentRef{Name: env.storeDocumentFile(t, "doc-"+strconv.Itoa(i), ".txt")})
}
if docs, err := env.srv.resolveChatDocuments(refs); err != nil || len(docs) != maxChatDocuments {
t.Fatalf("恰好 %d 条必须通过:docs=%d err=%v", maxChatDocuments, len(docs), err)
}
over := append(append([]chatDocumentRef(nil), refs...), chatDocumentRef{Name: validFormDocumentName("sixth")})
if _, err := env.srv.resolveChatDocuments(over); !errors.Is(err, apperr.ErrBadRequest) {
t.Fatalf("第 %d 条应被拒为 ErrBadRequest,得到 %v", maxChatDocuments+1, err)
}
var appErr *apperr.AppError
if _, err := env.srv.resolveChatDocuments(over); !errors.As(err, &appErr) || appErr.Code != tooManyDocumentsCode {
t.Fatalf("error.code = %q, want %q", appErr.Code, tooManyDocumentsCode)
}
tok := env.token(t)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web",
"text": "太多了",
"documents": over,
}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if code := decodeDocumentEnvelope(t, body).Error.Code; code != tooManyDocumentsCode {
t.Fatalf("error.code = %q, want %q", code, tooManyDocumentsCode)
}
if len(env.runner.snapshot()) != 0 {
t.Fatalf("被拒的请求起了回合 —— 附件条数必须在起回合之前判定")
}
// 恰好 5 条(都存在)照常放行:上限不得误伤边界。
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web",
"text": "这五份",
"documents": refs,
}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("恰好 %d 条必须通过:status = %d (body=%s)", maxChatDocuments, resp.StatusCode, body)
}
}
// TestChatDocumentsRejectMalformedOrMissingName 钉住存储名校验:形态非法(路径
// 穿越、非 32 位十六进制、白名单外扩展名、空名)与形态合法但不存在,一律
// 404 document_not_found(与读端点同一口径),且都不起回合。
func TestChatDocumentsRejectMalformedOrMissingName(t *testing.T) {
t.Parallel()
env := newChatDocumentsEnv(t)
// 形态校验必须真的在把关:目录里真有一份**非内容寻址**的同名文件时也不
// 能因为「它存在」就被放行 —— 客户端能引用的只有 S1 签发过的存储名。
dir := filepath.Join(env.dataDir, "uploads", documentSubDir)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("创建文档目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("不是内容寻址名"), 0o600); err != nil {
t.Fatalf("写入非内容寻址文件失败: %v", err)
}
cases := []struct{ label, name string }{
{"路径穿越", "../../../etc/passwd"},
{"反斜杠穿越", `..\..\cyrene.exe`},
{"主名不是 32 位十六进制", "deadbeef.txt"},
{"扩展名不在白名单", fmt.Sprintf("%032d.html", 1)},
{"空名", ""},
{"形态合法但不存在", validFormDocumentName("missing")},
{"非内容寻址但确实存在", "notes.txt"},
}
tok := env.token(t)
for _, tc := range cases {
_, err := env.srv.resolveChatDocuments([]chatDocumentRef{{Name: tc.name}})
if !errors.Is(err, apperr.ErrNotFound) {
t.Fatalf("%s: err = %v, want ErrNotFound", tc.label, err)
}
var appErr *apperr.AppError
if _, err := env.srv.resolveChatDocuments([]chatDocumentRef{{Name: tc.name}}); !errors.As(err, &appErr) ||
appErr.Code != documentNotFoundCode {
t.Fatalf("%s: error.code = %q, want %q", tc.label, appErr.Code, documentNotFoundCode)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web",
"text": "读一下",
"documents": []chatDocumentRef{{Name: tc.name}},
}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("%s: status = %d, want 404 (body=%s)", tc.label, resp.StatusCode, body)
}
if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentNotFoundCode {
t.Fatalf("%s: error.code = %q, want %q", tc.label, code, documentNotFoundCode)
}
}
if len(env.runner.snapshot()) != 0 {
t.Fatalf("被拒的请求起了回合 —— 存储名校验必须在起回合之前完成")
}
}
// TestChatDocumentsNeverFetchURLs 是 SSRF 红线的正向反证(design/24 §5):把
// 一个真实可命中的 URL 当作 name 发进来,网关**一次都不许去取它** —— 文档只认
// 服务端签发的存储名。
func TestChatDocumentsNeverFetchURLs(t *testing.T) {
t.Parallel()
env := newChatDocumentsEnv(t)
var hits atomic.Int64
evil := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer evil.Close()
tok := env.token(t)
names := []string{evil.URL + "/report.txt", "http://127.0.0.1:8080/api/v1/documents/whatever.txt"}
for _, name := range names {
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web",
"text": "读这个链接",
"documents": []chatDocumentRef{{Name: name}},
}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("URL 形态的 name 必须被拒:status = %d (body=%s)", resp.StatusCode, body)
}
}
if got := hits.Load(); got != 0 {
t.Fatalf("网关替客户端取了 URL %d 次 —— SSRF 面被重开", got)
}
if len(env.runner.snapshot()) != 0 {
t.Fatalf("被拒的请求起了回合")
}
}
// TestChatDocumentsStreamPathCarriesDocuments 钉住 `stream:true` 这条转发路径:
// 载荷必须把 documents 一并带到 hub(漏抄一个字段就是一次静默丢附件),因此
// 后台回合里同样能看到文档部件。
func TestChatDocumentsStreamPathCarriesDocuments(t *testing.T) {
t.Parallel()
env := newChatDocumentsEnv(t)
name := env.storeDocumentFile(t, "流式回合的附件", ".txt")
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{
"sessionId": "web",
"text": "",
"documents": []chatDocumentRef{{Name: name}},
"stream": true,
}, env.token(t))
if resp.StatusCode != http.StatusOK {
t.Fatalf("stream:true 未被受理:status = %d (body=%s)", resp.StatusCode, body)
}
calls := env.runner.waitCalls(t, 1)
if docs := chatDocumentsOf(calls[0]); len(docs) != 1 || docs[0].DocumentName != name {
t.Fatalf("流式路径丢掉了文档部件:%+v", calls[0])
}
}
// TestHubStreamChatResolvesDocuments 钉住 WS 帧(`chatStreamRequest` 与 REST
// 同形):带了 documents 就按同一条解析路径装配;未接解析器时**明确回一条
// error 帧**并中止本回合,绝不静默吞掉附件。
func TestHubStreamChatResolvesDocuments(t *testing.T) {
t.Parallel()
env := newChatDocumentsEnv(t)
name := env.storeDocumentFile(t, "ws 帧的附件", ".txt")
payload := map[string]any{"sessionId": "web", "text": "看这个", "documents": []chatDocumentRef{{Name: name}}}
hb := newTestHub(env.runner, nil)
hb.documents = env.srv.resolveChatDocuments
hb.streamChat(context.Background(), payload)
calls := env.runner.snapshot()
if len(calls) != 1 || len(chatDocumentsOf(calls[0])) != 1 {
t.Fatalf("WS 帧路径丢掉了文档部件:%+v", calls)
}
// 未接解析器(裸 hub):文档必须可见地失败,而不是被丢掉。
bare := newTestHub(env.runner, nil)
c := newOwnerConn(bare, 8)
bare.register(c)
bare.streamChat(context.Background(), payload)
ev := readFakeFrame(t, c)
if ev.Type != "error" {
t.Fatalf("帧类型 = %q, want error(附件解析失败必须可见)", ev.Type)
}
data, ok := ev.Data.(map[string]any)
if !ok || data["code"] != documentsUnavailableCode {
t.Fatalf("error data = %+v, want code=%q", ev.Data, documentsUnavailableCode)
}
if len(env.runner.snapshot()) != 1 {
t.Fatalf("解析失败的回合仍然起了 loop —— 必须在起回合之前拦住")
}
}
// TestTranscriptMessageKeepsDocumentReference 是 design/24 §9.5 第一处的回归锁:
// 从历史重建模型消息时必须带回文档引用(否则后续回合里附件凭空消失)。
func TestTranscriptMessageKeepsDocumentReference(t *testing.T) {
t.Parallel()
const name = "0123456789abcdef0123456789abcdef.txt"
raw, err := history.EncodeDocuments([]history.DocumentRef{{
Name: name, MIME: documentSniffOKMIME, Size: 12345, Status: history.DocumentStatusExtracted,
}})
if err != nil {
t.Fatalf("encode document: %v", err)
}
msg := transcriptMessage(history.Message{ID: "m1", Role: history.RoleUser, Content: "读一下", Document: raw})
docs := chatDocumentsOf([]model.Message{msg})
if len(docs) != 1 {
t.Fatalf("文档引用没能重建回来:%+v", msg)
}
if docs[0].DocumentName != name || docs[0].DocumentSize != 12345 ||
docs[0].DocumentURL != documentFilePrefix+name || docs[0].DocumentMIME != documentSniffOKMIME {
t.Fatalf("重建出的文档部件 = %+v, want name/mime/url/size 与落库引用一致", docs[0])
}
// 没有附件的消息照旧没有文档部件;坏列不 panic 也不产生部件(打 WARN)。
if got := chatDocumentsOf([]model.Message{
transcriptMessage(history.Message{ID: "m2", Role: history.RoleUser, Content: "无附件"}),
}); len(got) != 0 {
t.Fatalf("无附件消息产生了文档部件:%+v", got)
}
if got := chatDocumentsOf([]model.Message{
transcriptMessage(history.Message{ID: "m3", Role: history.RoleUser, Content: "坏列", Document: "{not json"}),
}); len(got) != 0 {
t.Fatalf("坏列产生了文档部件:%+v", got)
}
// 助手回合的说话者标注不受本次改动影响;附件不会落到非用户回合上。
assistant := transcriptMessage(history.Message{
ID: "m4", Role: history.RoleAssistant, Content: "答复", PersonaID: "cyrene", Document: raw,
})
if assistant.SenderName != "cyrene" {
t.Fatalf("助手消息的 SenderName = %q, want cyrene", assistant.SenderName)
}
if got := chatDocumentsOf([]model.Message{assistant}); len(got) != 0 {
t.Fatalf("文档部件落到了助手回合上:%+v", got)
}
}
// TestMessagesEmptyTreatsDocumentAsContent 是 design/24 §9.5 第二处的回归锁:
// 「只带附件」的回合必须算非空,否则纯文档消息会被判成空洞回合而整轮丢弃。
func TestMessagesEmptyTreatsDocumentAsContent(t *testing.T) {
t.Parallel()
o := NewOrchestrator(OrchestratorOptions{
Run: func(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) {
return &loop.Result{Text: "ok", StopReason: "stop"}, nil
},
})
docOnly := chatTurnMessages("", "", []model.Document{{Name: "0123456789abcdef0123456789abcdef.txt"}})
if o.messagesEmpty(docOnly) {
t.Fatalf("纯附件回合被判成空回合:%+v", docOnly)
}
if sr := o.Submit(context.Background(), "web", docOnly, loop.SessionEnv{}); sr.Empty || !sr.Accepted {
t.Fatalf("纯附件回合的判定 = %+v, want 被接收且非 Empty", sr)
}
// 反向:真·空回合仍然被判空(本次改动不得把空判定一起放宽)。
if !o.messagesEmpty([]model.Message{model.TextMessage(model.RoleUser, "")}) {
t.Fatal("空回合未被判空")
}
}
// TestMaxChatDocumentsMatchesDesign 单独把「单回合附件数上限的值」钉在设计定稿上。
//
// 为什么必须单独钉一次:TestChatDocumentsTooManyRejected 是**用本常量算输入条数**的
// (`make(..., maxChatDocuments+1)`、`for i < maxChatDocuments`),所以把常量改大它照样
// 绿 —— 实测把 5 改成 1000 时整个包仍全绿。也就是说「上限被悄悄改宽」这件事,除了这里
// 没有任何用例会发现。design/24 §9.4-3 的定稿值是 5。
func TestMaxChatDocumentsMatchesDesign(t *testing.T) {
t.Parallel()
if maxChatDocuments != 5 {
t.Fatalf("单回合附件上限 = %d, want 5(design/24 §9.4-3 的定稿值)", maxChatDocuments)
}
}