- 缺口①:「一回合最多 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 需重新拉取/回放可见)。
511 lines
20 KiB
Go
511 lines
20 KiB
Go
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)
|
||
}
|
||
}
|