feat(gateway): 语音回合进编排+落库、G5 队列满带回身份、voice_output 归属(安卓 G5/C1/C3)
- G5:pending_full 增 clientMsgId/messageId(仅在可给出时出现、绝不新铸 id —— 队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判重复而永远进不来);另定向回发送者 {type:error,data:{code:chat_busy,...}}(与 REST 409 chat_busy 同码),广播帧照旧带同样的键供其它端对齐。
- C1:语音回合改走与文本同一条路径(编排器 Submit → 落库 → 流式 → 跨端同步),deviceId 走既有 withDeviceID、platform 走存储默认 web;未接编排器时保留旧直连路径。音频在回合结算处(persistAssistantReply → persistDocumentStatus → dispatchVoiceReply)合成并定向回原连接,回调各自 goroutine(TTS 延迟不拖住 pending_drain/session_idle);续跑 FIFO 与转向注入两条路径都带走回调 ⇒ 忙时排队/被注入的语音也不丢音频。
- C3:voice_output 增 sessionId/deviceId;三处主动加码(按最优方向):① voice_input 增可选 clientMsgId(语音也有幂等)② 不再拿 stopReason 顶替空正文当语音内容(空正文走 provider 降级信号)③ TTS 失败照旧回帧 + provider:silent(不静默)。
- 拆 chat_documents_test.go(510 行越界)→ 373 + 160,只搬不改(拆前后同 -run 过滤 PASS 集合逐条一致)。
- 主会话独立验收:隔离 worktree(HEAD + 本批次 11 文件)build/vet 绿、gateway -race 绿、全树 59 包(唯一红是既有负载 flake orchestration/TestSpawnTimeoutFromToolDefault)、linux+darwin 交叉编译绿;变异独立复现(pending_full 摘掉 clientMsgId ⇒ 3 条红)。
- 待办(已登记):Docs/api-client.md 与 api-reference.md 的帧/字段登记随 v0.59 一并落笔(本批次刻意未动 Docs,避免与契约批次打架)。
This commit is contained in:
1 parent
72b95df22b
commit
3a01b4e236
11 files changed
+1123
-170
No files matched your search
@@ -28,7 +28,6 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -372,139 +371,3 @@ func TestChatDocumentsNeverFetchURLs(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user