Files
Cyrene_v2/internal/services/gateway/chat_documents_bounds_test.go
T
AskaEth 9b903a89bc
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime): IM 主人私聊补历史转录(与 Web 主会话同源:抽共享 transcript 包 + OwnerEnvFor 单入口)
2026-10-05 11:36:18 +08:00

162 lines
7.4 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):
// - `stream:true` 与 WS 帧两条转发路径都要把 documents 原样带走(漏抄字段即静默
// 丢附件);
// - 历史重建必须带回文档引用(S2 移交的第一处「静默吞掉」);
// - 「只带附件」的回合不算空回合(第二处「静默吞掉」);
// - 单回合附件上限的值本身钉在设计定稿上。
//
// 它从 chat_documents_test.go 拆出,使两个文件都留在「单文件 ≤500 行」的硬约束内;
// 两个文件是**同一批断言的搬家**,语义一字未改(夹具仍留在原文件,同包可见)。
//
// 断言一律走哨兵错误 + errors.Is / errors.As(机器可读的 code),不按错误文本
// 子串断言 —— 文案会变,契约不变。
import (
"context"
"net/http"
"testing"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
)
// 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 := transcript.Message(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{
transcript.Message(history.Message{ID: "m2", Role: history.RoleUser, Content: "无附件"}),
}); len(got) != 0 {
t.Fatalf("无附件消息产生了文档部件:%+v", got)
}
if got := chatDocumentsOf([]model.Message{
transcript.Message(history.Message{ID: "m3", Role: history.RoleUser, Content: "坏列", Document: "{not json"}),
}); len(got) != 0 {
t.Fatalf("坏列产生了文档部件:%+v", got)
}
// 助手回合的说话者标注不受本次改动影响;附件不会落到非用户回合上。
assistant := transcript.Message(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)
}
}