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) } }