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
@@ -0,0 +1,160 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,9 @@ type sessionState struct {
|
||||
// 结算时把抽取结论回写进那一列(design/24 §6.1)。它按代次配对,因此被中断/
|
||||
// 取代的陈旧回合读不到本回合的条目。见 session_document_status.go。
|
||||
turnDocs turnDocuments
|
||||
// voiceTurn 是本回合的语音收尾回调(协同 C1),按代次配对;纯文本回合记为
|
||||
// 零值。见 session_voice.go。
|
||||
voiceTurn voiceTurn
|
||||
}
|
||||
|
||||
// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑
|
||||
@@ -169,6 +172,9 @@ type pendingMsg struct {
|
||||
// 文档抽取结论(design/24 §6.1):它在入队那一刻就落了库,而那时抽取还没
|
||||
// 发生。见 session_document_status.go。
|
||||
persisted []history.Message
|
||||
// voice 是这条待处理回合的语音收尾回调(协同 C1):它源自 voice_input 时
|
||||
// 非 nil,使续跑回合结算后仍能把音频定向回发起它的那根连接。
|
||||
voice VoiceReplyFunc
|
||||
}
|
||||
|
||||
// Orchestrator 拥有每会话的消息状态机。它对并发安全
|
||||
@@ -257,8 +263,9 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
// 状态事件在这里解锁之后再发。
|
||||
a := o.admitLocked(ctx, st, sessionID)
|
||||
if a.full {
|
||||
rejected := o.rejectedIdentityLocked(ctx, st, sessionID)
|
||||
o.mu.Unlock()
|
||||
o.emitState(sessionID, EventPendingFull, map[string]any{"sessionId": sessionID, "mode": a.mode, "max": o.maxPending})
|
||||
o.emitPendingFull(ctx, sessionID, a.mode, rejected)
|
||||
return SubmitResult{PendingFull: true}, nil
|
||||
}
|
||||
if a.dup {
|
||||
@@ -272,7 +279,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
if st.mode != ModeIdle {
|
||||
st.pendingSeq++
|
||||
seq := st.pendingSeq
|
||||
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now(), seq: seq})
|
||||
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now(), seq: seq, voice: voiceReplySinkFrom(ctx)})
|
||||
depth := len(st.pending)
|
||||
mode := st.mode
|
||||
o.mu.Unlock()
|
||||
@@ -305,6 +312,8 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
// 且落库只写**引用** —— 抽取还发生在后面,结论由回合结算时补进同一列
|
||||
// (两段式见 session_document_status.go)。
|
||||
o.rememberTurnDocuments(sessionID, gen, o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID))
|
||||
// 语音收尾回调同样按代次配对(协同 C1,见 session_voice.go)。
|
||||
o.rememberVoiceTurn(sessionID, gen, voiceReplySinkFrom(ctx))
|
||||
|
||||
if sync {
|
||||
return o.runImmediateSync(runCtx, sessionID, gen, turn, env, fence, deviceID, msgMode, a.hint.MessageID)
|
||||
@@ -357,10 +366,12 @@ func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Mes
|
||||
//
|
||||
// 它同时是文档抽取结论回写的落点(design/24 §6.1):本回合用户消息的
|
||||
// document 列里此前只有引用,装配期解析出的 status/chars/truncated 在这里补上
|
||||
// —— 见 session_document_status.go 的两段式说明。
|
||||
// —— 见 session_document_status.go;语音回合的音频回复同样在此收尾(协同 C1,
|
||||
// 见 session_voice.go):只有这里知道这个回合真正产出了什么。
|
||||
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
|
||||
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
|
||||
o.persistDocumentStatus(sessionID, gen, res)
|
||||
o.dispatchVoiceReply(sessionID, gen, res)
|
||||
o.mu.Lock()
|
||||
st := o.states[sessionID]
|
||||
if st == nil || st.gen != gen {
|
||||
@@ -389,6 +400,9 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
|
||||
// 续跑的这一轮同样有「回合前已落库的用户消息」,因此把它们的文档状态
|
||||
// 记到新代次上(design/24 §6.1)。
|
||||
st.turnDocs = turnDocuments{gen: gen, msgs: next.persisted}
|
||||
// 续跑的这条若源自语音请求,它的音频回复照旧定向回发起它的那根连接
|
||||
// (协同 C1:忙时排队不许把音频丢掉)。
|
||||
st.voiceTurn = voiceTurn{gen: gen, sinks: voiceSinksOf(next.voice)}
|
||||
depth := len(st.pending)
|
||||
resume := append([]model.Message(nil), st.tail...)
|
||||
env := next.env
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件是协同项 G5 的回归:pending_full 必须说清「是哪一条被丢了」。
|
||||
//
|
||||
// 修前:pending_full 只带 {sessionId, mode, max},客户端收到「队列满」时无法判定
|
||||
// 该收掉哪一条本地乐观气泡(WS 路径也没有 REST 那 409 chat_busy 的等价物),
|
||||
// 那条气泡会永远挂在「发送中…」。
|
||||
//
|
||||
// 修后:帧里带上被拒那条的 clientMsgId(未携带时省略、绝不伪造),该键此前已
|
||||
// 落库/已认领时再带上 messageId;发送者还会收到一条定向拒绝帧(type=error、
|
||||
// data.code=chat_busy,与 REST 同码)。既有三个字段一个不改。
|
||||
//
|
||||
// 断言一律按字段判定,不按错误文本子串。
|
||||
|
||||
// pendingFullData 返回记录型汇聚点里最近一次 pending_full 的载荷。
|
||||
func pendingFullData(t *testing.T, sink *recordingSink) map[string]any {
|
||||
t.Helper()
|
||||
sink.mu.Lock()
|
||||
defer sink.mu.Unlock()
|
||||
for i := len(sink.events) - 1; i >= 0; i-- {
|
||||
if sink.events[i].typ == EventPendingFull {
|
||||
return sink.events[i].data
|
||||
}
|
||||
}
|
||||
t.Fatal("没有收到 pending_full 事件")
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillPendingQueue 把 "s1" 推到「有一个在飞回合 + 待处理队列已满」的状态。
|
||||
func fillPendingQueue(t *testing.T, o *Orchestrator, maxPending int) {
|
||||
t.Helper()
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
|
||||
t.Fatalf("A = %+v, want Immediate", sr)
|
||||
}
|
||||
for i := 0; i < maxPending; i++ {
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("p")}, loop.OwnerEnv()); !sr.Pending {
|
||||
t.Fatalf("第 %d 条排队 = %+v, want Pending", i, sr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingFullCarriesRejectedClientMsgId 钉住主路径:队列满且新消息带
|
||||
// clientMsgId ⇒ pending_full 载荷含该键;未落库时**不得**凭空给出 messageId。
|
||||
func TestPendingFullCarriesRejectedClientMsgId(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(8)
|
||||
o, sink := seedOrchestrator(run, 1)
|
||||
fillPendingQueue(t, o, 1)
|
||||
|
||||
ctx := withClientMsgHint(context.Background(), clientMsgHint{ClientMsgID: "cm-42"})
|
||||
if sr := o.Submit(ctx, "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv()); !sr.PendingFull {
|
||||
t.Fatalf("overflow = %+v, want PendingFull", sr)
|
||||
}
|
||||
data := pendingFullData(t, sink)
|
||||
if data["clientMsgId"] != "cm-42" {
|
||||
t.Fatalf("pending_full.clientMsgId = %v, want cm-42(客户端必须知道是哪条被丢)", data["clientMsgId"])
|
||||
}
|
||||
if _, present := data["messageId"]; present {
|
||||
t.Fatalf("未落库的拒绝不许伪造 messageId:%+v", data)
|
||||
}
|
||||
// 既有字段一个不改、不删。
|
||||
for _, key := range []string{"sessionId", "mode", "max"} {
|
||||
if _, ok := data[key]; !ok {
|
||||
t.Fatalf("pending_full 丢了既有字段 %q:%+v", key, data)
|
||||
}
|
||||
}
|
||||
if data["sessionId"] != "s1" || data["max"] != 1 {
|
||||
t.Fatalf("既有字段的值被改动:%+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingFullOmitsIdentityWhenAbsent 钉住「省略、不伪造」:老客户端不带
|
||||
// clientMsgId 时,两个新增键都必须缺席(而不是回一个空串或编造的 id)。
|
||||
func TestPendingFullOmitsIdentityWhenAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(8)
|
||||
o, sink := seedOrchestrator(run, 1)
|
||||
fillPendingQueue(t, o, 1)
|
||||
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv()); !sr.PendingFull {
|
||||
t.Fatalf("overflow = %+v, want PendingFull", sr)
|
||||
}
|
||||
data := pendingFullData(t, sink)
|
||||
if _, present := data["clientMsgId"]; present {
|
||||
t.Fatalf("未携带 clientMsgId 时该键必须缺席:%+v", data)
|
||||
}
|
||||
if _, present := data["messageId"]; present {
|
||||
t.Fatalf("未落库时 messageId 必须缺席:%+v", data)
|
||||
}
|
||||
if data["sessionId"] != "s1" {
|
||||
t.Fatalf("既有字段必须照旧:%+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingFullCarriesMessageIdWhenAlreadyClaimed 钉住「已落库则带身份」:同一个
|
||||
// clientMsgId 此前已经认领并落库时,队列满的拒绝要把它已有(且终将存在)的
|
||||
// messageId 一并给出,让客户端能把那条气泡直接对齐,而不是重试一次。
|
||||
func TestPendingFullCarriesMessageIdWhenAlreadyClaimed(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(8)
|
||||
sink := &recordingSink{}
|
||||
o := NewOrchestrator(OrchestratorOptions{
|
||||
Run: run.Run,
|
||||
Emit: sink.Emit,
|
||||
Log: slog.Default(),
|
||||
MaxPending: 1,
|
||||
MaxTail: 20,
|
||||
History: &fakeHistory{},
|
||||
})
|
||||
ctx := withClientMsgHint(context.Background(), clientMsgHint{ClientMsgID: "cm-7"})
|
||||
first := o.Submit(ctx, "s1", []model.Message{userMsg("第一次")}, loop.OwnerEnv())
|
||||
if !first.Immediate || first.MessageID == "" {
|
||||
t.Fatalf("第一次提交 = %+v, want Immediate 且带预分配 messageId", first)
|
||||
}
|
||||
fillPendingQueueAfterFirst(t, o, 1)
|
||||
|
||||
retry := o.Submit(ctx, "s1", []model.Message{userMsg("重发同一条")}, loop.OwnerEnv())
|
||||
if !retry.PendingFull {
|
||||
t.Fatalf("重发 = %+v, want PendingFull(队列满是先于判重的第一道门)", retry)
|
||||
}
|
||||
data := pendingFullData(t, sink)
|
||||
if data["clientMsgId"] != "cm-7" {
|
||||
t.Fatalf("clientMsgId = %v, want cm-7", data["clientMsgId"])
|
||||
}
|
||||
if data["messageId"] != first.MessageID {
|
||||
t.Fatalf("messageId = %v, want %q(该键此前已落库,必须给出已有的那条消息 id)", data["messageId"], first.MessageID)
|
||||
}
|
||||
}
|
||||
|
||||
// fillPendingQueueAfterFirst 在已有一个在飞回合的前提下填满待处理队列。
|
||||
func fillPendingQueueAfterFirst(t *testing.T, o *Orchestrator, maxPending int) {
|
||||
t.Helper()
|
||||
for i := 0; i < maxPending; i++ {
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("排队")}, loop.OwnerEnv()); !sr.Pending {
|
||||
t.Fatalf("第 %d 条排队 = %+v, want Pending", i, sr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newOrchestratedTestHub 构建一个接了编排器的 hub(回合驱动器由调用方给出),使
|
||||
// 「必须在编排器之下才成立」的行为(语音回合、队列满拒绝)能被单元级回归。
|
||||
func newOrchestratedTestHub(t *testing.T, run TurnRunner, hist HistoryStore) *hub {
|
||||
t.Helper()
|
||||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "ok", StopReason: "stop"}}, nil)
|
||||
hb.orchestrator = NewOrchestrator(OrchestratorOptions{
|
||||
Run: run,
|
||||
Emit: func(sessionID, typ string, data map[string]any) {
|
||||
hb.broadcast(wsEvent{Type: typ, SessionID: sessionID, Data: data})
|
||||
},
|
||||
Log: sourcedLog(nil),
|
||||
MaxTail: 20,
|
||||
History: hist,
|
||||
MaxPending: defaultMaxPending,
|
||||
})
|
||||
return hb
|
||||
}
|
||||
|
||||
// TestPendingFullNotifiesSenderDirectly 钉住定向拒绝帧:队列满时,发送这条消息的
|
||||
// 连接必须收到一条 {type:"error", data:{code:"chat_busy"}}(与 REST 的 409 同码),
|
||||
// 且带上被打回那条的 clientMsgId;不带 clientMsgId 时该键缺席。
|
||||
func TestPendingFullNotifiesSenderDirectly(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(8)
|
||||
hb := newOrchestratedTestHub(t, run.Run, nil)
|
||||
c := newOwnerConn(hb, 64)
|
||||
hb.register(c)
|
||||
|
||||
// 用编排器直接填队列:三条 route 各自起 goroutine,提交顺序不确定,而这道
|
||||
// 门本身依赖「先忙起来、再满」。
|
||||
fillPendingQueue(t, hb.orchestrator, defaultMaxPending)
|
||||
if got := hb.orchestrator.QueueDepth("s1"); got != defaultMaxPending {
|
||||
t.Fatalf("队列深度 = %d, want %d", got, defaultMaxPending)
|
||||
}
|
||||
|
||||
// 第三条经 WS 帧进来 ⇒ 它带着「定向回这条连接」的回调。
|
||||
c.route(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"s1","text":"overflow","clientMsgId":"cm-9"}`)})
|
||||
full, rejected := waitFullAndRejection(t, c)
|
||||
if full["clientMsgId"] != "cm-9" {
|
||||
t.Fatalf("广播的 pending_full.clientMsgId = %v, want cm-9", full["clientMsgId"])
|
||||
}
|
||||
if rejected["clientMsgId"] != "cm-9" || rejected["code"] != wsChatBusyCode {
|
||||
t.Fatalf("定向拒绝帧 = %+v, want clientMsgId=cm-9 且 code=%q", rejected, wsChatBusyCode)
|
||||
}
|
||||
if rejected["sessionId"] != "s1" {
|
||||
t.Fatalf("定向拒绝帧必须标明会话:%+v", rejected)
|
||||
}
|
||||
if _, present := rejected["messageId"]; present {
|
||||
t.Fatalf("未落库的拒绝不许伪造 messageId:%+v", rejected)
|
||||
}
|
||||
|
||||
// 老客户端(不带 clientMsgId):照旧收到拒绝帧,但两个身份键都必须缺席。
|
||||
c.route(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"s1","text":"again"}`)})
|
||||
_, rejected = waitFullAndRejection(t, c)
|
||||
if rejected["code"] != wsChatBusyCode {
|
||||
t.Fatalf("定向拒绝帧 = %+v, want code=%q", rejected, wsChatBusyCode)
|
||||
}
|
||||
for _, key := range []string{"clientMsgId", "messageId"} {
|
||||
if _, present := rejected[key]; present {
|
||||
t.Fatalf("未携带身份时 %q 必须缺席:%+v", key, rejected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitFullAndRejection 从连接上收集一条 pending_full 广播与一条定向拒绝帧。
|
||||
func waitFullAndRejection(t *testing.T, c *wsConn) (full, rejected map[string]any) {
|
||||
t.Helper()
|
||||
deadline := time.After(3 * time.Second)
|
||||
for full == nil || rejected == nil {
|
||||
select {
|
||||
case b := <-c.send:
|
||||
var ev wsEvent
|
||||
if err := json.Unmarshal(b, &ev); err != nil {
|
||||
t.Fatalf("解码帧 %s: %v", b, err)
|
||||
}
|
||||
data, _ := ev.Data.(map[string]any)
|
||||
switch {
|
||||
case ev.Type == EventPendingFull:
|
||||
full = data
|
||||
case ev.Type == "error" && data["code"] == wsChatBusyCode:
|
||||
rejected = data
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("等待 pending_full / 定向拒绝帧超时(full=%v rejected=%v)", full, rejected)
|
||||
}
|
||||
}
|
||||
return full, rejected
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package gateway
|
||||
|
||||
import "context"
|
||||
|
||||
// 本文件归属协同项 G5 的「队列满的拒绝要说清是哪一条」:pending_full 状态帧的
|
||||
// 载荷回填,以及发往**发送者连接**的那条定向拒绝帧。
|
||||
//
|
||||
// 背景:pending_full 此前只带 {sessionId, mode, max},于是客户端收到「队列满」
|
||||
// 时无法判定该收掉哪一条本地乐观气泡 —— 那条气泡会永远挂在「发送中…」(REST
|
||||
// 路径有 409 chat_busy 可以回滚,WS 路径没有等价物)。这里给出确定的键,并让
|
||||
// 发送者收到一条能精确处理的定向帧。
|
||||
//
|
||||
// 它与 ws 边界的关系:编排器不认识连接,只认识 ctx 上由传输层给出的「怎么把
|
||||
// 拒绝送回发送者」回调(与 deviceID / mode / 幂等键同一种机制)。REST 路径不接
|
||||
// 回调 —— 它的 409 chat_busy 承担同一职责。
|
||||
|
||||
// PendingFullNotifier 把「本次提交因待处理队列已满被拒」定向通知给发送者。
|
||||
// 它只作用于发起这条消息的那根连接:广播的 pending_full 是状态信号,各端自行
|
||||
// 按 clientMsgId 对齐,其它连接忽略即可。clientMsgID 与 messageID 都可能为 ""
|
||||
// (该键省略,绝不伪造)。
|
||||
type PendingFullNotifier func(sessionID, clientMsgID, messageID string)
|
||||
|
||||
// pendingFullNotifierKey 是携带定向拒绝回调的 context 键。它从 WS 帧边界
|
||||
// (ws_route.go 的 submitChat / voice.go 的 runVoiceTurn)引入编排器。
|
||||
type pendingFullNotifierKey struct{}
|
||||
|
||||
// withPendingFullNotifier 返回携带定向拒绝回调的 context。nil 回调是空操作:
|
||||
// 未接线传输层(REST / 单元测试)的提交在 ctx 上不留任何痕迹。
|
||||
func withPendingFullNotifier(ctx context.Context, n PendingFullNotifier) context.Context {
|
||||
if n == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, pendingFullNotifierKey{}, n)
|
||||
}
|
||||
|
||||
// pendingFullNotifierFrom 返回 ctx 携带的定向拒绝回调,未设置时为 nil。
|
||||
func pendingFullNotifierFrom(ctx context.Context) PendingFullNotifier {
|
||||
n, _ := ctx.Value(pendingFullNotifierKey{}).(PendingFullNotifier)
|
||||
return n
|
||||
}
|
||||
|
||||
// emitPendingFull 广播 pending_full 状态帧,并在接线了发送者回调时定向回一条
|
||||
// 拒绝帧。调用方必须已从状态锁退出(状态锁内不做广播,与既有纪律一致)。
|
||||
func (o *Orchestrator) emitPendingFull(ctx context.Context, sessionID string, mode Mode, rejected clientMsgHint) {
|
||||
o.emitState(sessionID, EventPendingFull, pendingFullPayload(sessionID, mode, o.maxPending, rejected))
|
||||
if n := pendingFullNotifierFrom(ctx); n != nil {
|
||||
n(sessionID, rejected.ClientMsgID, rejected.MessageID)
|
||||
}
|
||||
}
|
||||
|
||||
// rejectedIdentityLocked 为队列满被拒的那条消息取「服务端能给出的身份」(G5):
|
||||
// clientMsgId 原样来自 ctx(省略时为空 ⇒ 该字段被省略);messageId 只在它**此前
|
||||
// 已经落库或已认领**时给出。
|
||||
//
|
||||
// 它绝不新铸 id:队列满的拒绝发生在认领之前,一旦在这里认领一个新 id,客户端用
|
||||
// 同一个键重试就会被判成「重复到达」而永远进不来(见 admitLocked 的顺序说明)。
|
||||
// 因此未落库时只报 clientMsgId。必须在 o.mu 下调用。
|
||||
func (o *Orchestrator) rejectedIdentityLocked(ctx context.Context, st *sessionState, sessionID string) clientMsgHint {
|
||||
id := clientMsgIDFrom(ctx)
|
||||
if id == "" {
|
||||
return clientMsgHint{}
|
||||
}
|
||||
if msgID, ok := st.clientMsgs[id]; ok {
|
||||
return clientMsgHint{ClientMsgID: id, MessageID: msgID}
|
||||
}
|
||||
if msgID, ok := o.findPersistedClientMsg(ctx, sessionID, id); ok {
|
||||
o.rememberClientMsgLocked(st, id, msgID)
|
||||
return clientMsgHint{ClientMsgID: id, MessageID: msgID}
|
||||
}
|
||||
return clientMsgHint{ClientMsgID: id}
|
||||
}
|
||||
|
||||
// pendingFullPayload 组装 pending_full 状态帧的载荷。既有三个键
|
||||
// (sessionId / mode / max)**一个不改**,新增键是纯增量:
|
||||
// - clientMsgId:被拒那条消息的客户端幂等键(未携带时该键不出现);
|
||||
// - messageId:该键此前已落库/已认领时的消息 id(未落库时该键不出现)。
|
||||
func pendingFullPayload(sessionID string, mode Mode, max int, rejected clientMsgHint) map[string]any {
|
||||
data := map[string]any{"sessionId": sessionID, "mode": mode, "max": max}
|
||||
if rejected.ClientMsgID != "" {
|
||||
data["clientMsgId"] = rejected.ClientMsgID
|
||||
}
|
||||
if rejected.MessageID != "" {
|
||||
data["messageId"] = rejected.MessageID
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -220,6 +220,9 @@ func (o *Orchestrator) consumePending(sessionID string, gen uint64, seqs map[uin
|
||||
for _, p := range st.pending {
|
||||
if seqs[p.seq] {
|
||||
removed = append(removed, p.messages...)
|
||||
// 被注入的若是语音请求,它的音频要由本回合结算时一并送回(协同 C1);
|
||||
// 否则那条请求的回合已被本回合接手,它却永远等不到 voice_output。
|
||||
o.addVoiceSinkLocked(st, gen, p.voice)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件归属协同项 C1 的「语音回合收尾」:把一次语音回合的结果交回语音层,
|
||||
// 由它合成音频并**定向**回发起这条语音的连接。
|
||||
//
|
||||
// 语音回合现在与文本回合走同一条路(进编排器 → 落库 → 流式 → 跨端同步),
|
||||
// 因此音频回复不能还在提交那一刻同步等待 —— 回合是异步完成的(可能排队、
|
||||
// 可能被中断)。编排器唯一知道「这个回合真正产出了什么」的地方是回合结算处
|
||||
// (completeTurn,与 S3.5 回写文档结论同一收尾点),所以收尾回调在那里触发。
|
||||
//
|
||||
// 与 session_reject.go 同一纪律:编排器对传输层一无所知,「怎么把帧送回那根
|
||||
// 连接」由 WS 边界在 ctx 上给出。
|
||||
|
||||
// VoiceReplyFunc 是语音回合的收尾回调:回合结算后,编排器把该回合的结果交给
|
||||
// 它(res 可能为 nil —— 被取消或出错的回合没有结果)。
|
||||
type VoiceReplyFunc func(res *loop.Result)
|
||||
|
||||
// voiceReplyKey 是携带语音收尾回调的 context 键。它从 WS 帧边界
|
||||
// (voice.go 的 runVoiceTurn)引入编排器,与 deviceID / 幂等键同一种机制。
|
||||
type voiceReplyKey struct{}
|
||||
|
||||
// withVoiceReplySink 返回携带语音收尾回调的 context。nil 回调是空操作:文本
|
||||
// 回合(以及未接线传输层的提交)在 ctx 上不留任何痕迹。
|
||||
func withVoiceReplySink(ctx context.Context, sink VoiceReplyFunc) context.Context {
|
||||
if sink == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, voiceReplyKey{}, sink)
|
||||
}
|
||||
|
||||
// voiceReplySinkFrom 返回 ctx 携带的语音收尾回调,未设置时为 nil。
|
||||
func voiceReplySinkFrom(ctx context.Context) VoiceReplyFunc {
|
||||
sink, _ := ctx.Value(voiceReplyKey{}).(VoiceReplyFunc)
|
||||
return sink
|
||||
}
|
||||
|
||||
// voiceTurn 配对一次语音回合与它的收尾回调(按代次配对,与 turnDocuments
|
||||
// 同理):被打断或被新回合取代的陈旧回合在结算时不该替别人回音频。
|
||||
//
|
||||
// sinks 是切片,因为一个回合可能同时有**多条**语音请求在等音频:忙时入 pending
|
||||
// 的语音消息可能被转向注入(design/15 §2.4,见 session_steer.go 的 consumePending)
|
||||
// 而由同一个回合作答 —— 它的音频必须由这个回合结算时一并送回,否则那条请求会
|
||||
// 静默地收不到声音(音频回复不能丢)。
|
||||
type voiceTurn struct {
|
||||
gen uint64
|
||||
sinks []VoiceReplyFunc
|
||||
}
|
||||
|
||||
// voiceSinksOf 把单个回调收成切片(nil 回调 → nil 切片)。
|
||||
func voiceSinksOf(sink VoiceReplyFunc) []VoiceReplyFunc {
|
||||
if sink == nil {
|
||||
return nil
|
||||
}
|
||||
return []VoiceReplyFunc{sink}
|
||||
}
|
||||
|
||||
// rememberVoiceTurn 记下本回合的语音收尾回调。**每个新回合都必须调用它一次**
|
||||
// (纯文本回合记 nil):否则一个陈旧的语音回调会被沿用到后面的文本回合上,
|
||||
// 让一次文本问答无端合成一段音频。
|
||||
func (o *Orchestrator) rememberVoiceTurn(sessionID string, gen uint64, sink VoiceReplyFunc) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if st := o.states[sessionID]; st != nil {
|
||||
st.voiceTurn = voiceTurn{gen: gen, sinks: voiceSinksOf(sink)}
|
||||
}
|
||||
}
|
||||
|
||||
// addVoiceSinkLocked 把一条**被转向注入**的语音请求的收尾回调追加到当前回合上。
|
||||
// 必须在 o.mu 下调用;代次不符(那条请求不属于本回合)时什么都不做。
|
||||
func (o *Orchestrator) addVoiceSinkLocked(st *sessionState, gen uint64, sink VoiceReplyFunc) {
|
||||
if st == nil || sink == nil || st.voiceTurn.gen != gen {
|
||||
return
|
||||
}
|
||||
st.voiceTurn.sinks = append(st.voiceTurn.sinks, sink)
|
||||
}
|
||||
|
||||
// takeVoiceTurn 取走与 gen 配对的所有语音收尾回调并清空记录。第二个返回值为
|
||||
// false 表示代次已陈旧(中断/取代):迟到的结算不该回音频。
|
||||
func (o *Orchestrator) takeVoiceTurn(sessionID string, gen uint64) ([]VoiceReplyFunc, bool) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
st := o.states[sessionID]
|
||||
if st == nil || st.voiceTurn.gen != gen {
|
||||
return nil, false
|
||||
}
|
||||
sinks := st.voiceTurn.sinks
|
||||
st.voiceTurn = voiceTurn{}
|
||||
return sinks, true
|
||||
}
|
||||
|
||||
// dispatchVoiceReply 在回合结算时把结果交回语音层(协同 C1)。不是语音回合时
|
||||
// 是空操作;被取代的陈旧回合同样什么都不做。
|
||||
//
|
||||
// 每个回调在自己的 goroutine 上跑:TTS 是网络调用,绝不许它拖住编排器的状态迁移
|
||||
// (助手回复此刻已经落库,音频是它之后的增强;pending 续跑与 session_idle
|
||||
// 不该等一段语音合成)。
|
||||
func (o *Orchestrator) dispatchVoiceReply(sessionID string, gen uint64, res *loop.Result) {
|
||||
sinks, ok := o.takeVoiceTurn(sessionID, gen)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, sink := range sinks {
|
||||
go sink(res)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"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/multimodal/voice"
|
||||
@@ -87,6 +89,10 @@ type voiceInputRequest struct {
|
||||
Text string `json:"text"`
|
||||
SessionID string `json:"sessionId"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
// ClientMsgID 是客户端自带的幂等键(协同 G4),与文本 chat 帧**同形**:
|
||||
// 同一个非空 clientMsgId 只承认第一条,重复到达不再落库、不再起回合
|
||||
// (回一条 chat_duplicate)。它可选,省略时行为与加入该字段之前逐字相同。
|
||||
ClientMsgID string `json:"clientMsgId,omitempty"`
|
||||
}
|
||||
|
||||
// SetVoice 注入 voice_input WS 路由所用的语音 STT/TTS 服务。
|
||||
@@ -99,9 +105,9 @@ func (s *Server) SetVoice(v VoiceService) {
|
||||
}
|
||||
|
||||
// handleVoiceInput 路由一个 voice_input 帧。未注入语音
|
||||
// 服务时它以明确错误应答(绝不崩溃);否则它解码
|
||||
// 请求并异步运行 STT→chat→TTS 回合,使读泵绝不被
|
||||
// provider 延迟阻塞。
|
||||
// 服务时它以明确错误应答(绝不崩溃);否则它异步跑这个语音
|
||||
// 回合(STT → 编排器 → 结算时的 TTS),使读泵绝不被
|
||||
// provider 或模型延迟阻塞。
|
||||
func (c *wsConn) handleVoiceInput(in wsInbound) {
|
||||
if c.h.voice == nil {
|
||||
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "voice unavailable"}})
|
||||
@@ -116,14 +122,24 @@ func (c *wsConn) handleVoiceInput(in wsInbound) {
|
||||
go c.h.runVoiceTurn(c, req)
|
||||
}
|
||||
|
||||
// runVoiceTurn 驱动一个语音回合:STT(或提供的转写)→ agent chat
|
||||
// → TTS,然后向请求连接回复一个 voice_output 帧,
|
||||
// 携带 base64 音频加上识别出的转写与助手回复。
|
||||
// voiceLogSource 是语音路径的日志来源标签,使这条路径上的 WARN 能被追溯到
|
||||
// 具体子功能(AGENTS §10 的「日志必须标来源」)。
|
||||
const voiceLogSource = "gateway/voice"
|
||||
|
||||
// runVoiceTurn 驱动一个语音回合。转写(STT)仍在这里完成 —— 识别是语音入口
|
||||
// 自己的事;而**回合**从此刻起与文本回合走同一条路:进编排器 → 落库 → 流式 →
|
||||
// 跨端同步(协同 C1)。此前它直调 runner.Run,于是语音问过的事不进历史、不跨端、
|
||||
// 也不参与 busy/pending。
|
||||
//
|
||||
// 音频回复不在本函数等待:回合是异步完成的(可能排队、可能被中断),因此 TTS 与
|
||||
// 定向回帧由收尾回调(voiceReplySink)在回合**结算**时完成 —— 那是编排器唯一
|
||||
// 知道这个回合产出了什么的地方(与 S3.5 回写文档结论同一收尾点)。
|
||||
func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
|
||||
ctx := context.Background()
|
||||
// 把客户端的 deviceID 串下来,使持久化的语音回合被发送端
|
||||
// 标注(design/16 §4.5),与 ws_hub.go:240 一致。
|
||||
ctx = withDeviceID(ctx, req.DeviceID)
|
||||
// 把客户端的 deviceID 与可选幂等键串下来,使持久化的语音回合被发送端标注
|
||||
//(design/16 §4.5)并让重复到达的 clientMsgId 被判重(协同 G4)—— 与
|
||||
// ws_hub_turn.go 的文本路径同一机制。
|
||||
ctx = withClientMsgHint(withDeviceID(ctx, req.DeviceID), clientMsgHint{ClientMsgID: req.ClientMsgID})
|
||||
if req.SessionID == "" {
|
||||
req.SessionID = "web"
|
||||
}
|
||||
@@ -135,13 +151,13 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
|
||||
}
|
||||
audio, err := decodeVoiceAudio(req.Audio, req.Format)
|
||||
if err != nil {
|
||||
h.log.Warn("voice audio decode", "err", err)
|
||||
h.voiceLog().Warn("voice audio decode", "err", err, "session", req.SessionID)
|
||||
h.voiceError(c, "voice audio could not be decoded")
|
||||
return
|
||||
}
|
||||
stt, err := h.voice.STT(ctx, audio)
|
||||
if err != nil {
|
||||
h.log.Warn("voice stt", "err", err)
|
||||
h.voiceLog().Warn("voice stt", "err", err, "session", req.SessionID)
|
||||
h.voiceError(c, "voice transcription failed")
|
||||
return
|
||||
}
|
||||
@@ -155,38 +171,110 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
|
||||
h.voiceError(c, "chat service is not configured")
|
||||
return
|
||||
}
|
||||
// Web 主会话是主人的私密对话,因此它绑定
|
||||
// 全访问 env:记忆读+写,行为不变。角色来自该
|
||||
// 会话的绑定(design/22 §4.5)。
|
||||
// Web 主会话是主人的私密对话,因此它绑定全访问 env:记忆读+写,行为不变。
|
||||
// 角色来自该会话的绑定(design/22 §4.5)。
|
||||
env := h.envResolver.owner(ctx, req.SessionID)
|
||||
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)})
|
||||
msgs := []model.Message{model.TextMessage(model.RoleUser, transcript)}
|
||||
// 两条「怎么回到这条连接」的通路都由本连接给出:收尾回调(音频回复,C1)与
|
||||
// 定向拒绝回调(队列满,G5)。编排器只把它们当成本回合的传输线索。
|
||||
ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, transcript))
|
||||
ctx = withPendingFullNotifier(ctx, c.notifyPendingFull)
|
||||
if h.orchestrator != nil {
|
||||
h.orchestrator.Submit(ctx, req.SessionID, msgs, env)
|
||||
return
|
||||
}
|
||||
h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, transcript, env)
|
||||
}
|
||||
|
||||
// runVoiceTurnDirect 是未接编排器时的旧直跑路径(未接线的构建 / 裸 hub 单元
|
||||
// 测试):转写之后的回合直接跑 loop,然后立即合成并回音频。它与编排器路径共用
|
||||
// 同一个帧构造(emitVoiceOutput),因此两条路径的 voice_output 契约一致。
|
||||
func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, deviceID, transcript string, env loop.SessionEnv) {
|
||||
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)})
|
||||
if err != nil {
|
||||
h.log.Warn("voice chat run", "err", err, "session", req.SessionID)
|
||||
h.voiceLog().Warn("voice chat run", "err", err, "session", sessionID, "device_id", deviceID)
|
||||
h.voiceError(c, "voice chat failed")
|
||||
return
|
||||
}
|
||||
reply := res.Text
|
||||
h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, transcript, voiceReplyText(res))
|
||||
}
|
||||
|
||||
// voiceReplySink 返回本次语音请求的收尾回调:回合结算后取助手正文 → TTS →
|
||||
// **定向**回发起它的那根连接。它是 C1 的核心接线点 —— 回合异步完成,只有结算处
|
||||
// 知道助手正文。
|
||||
//
|
||||
// 连接已断(客户端走了)时记一条带来源的 WARN,绝不静默丢:那正是「静默成功但
|
||||
// 没声音」那类缺陷的温床(协同 C2 的教训)。
|
||||
func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, transcript string) VoiceReplyFunc {
|
||||
return func(res *loop.Result) {
|
||||
if c.closed() {
|
||||
h.voiceLog().Warn("voice reply dropped", "reason", "connection closed", "session", sessionID, "device_id", deviceID)
|
||||
return
|
||||
}
|
||||
// 回合的 ctx 已随回合消亡,因此合成用全新的后台 context:音频是「回复已
|
||||
// 落库」之后的增强,不该被一次被取消的回合连带取消。
|
||||
h.synthesizeVoiceReply(context.Background(), c, sessionID, deviceID, transcript, voiceReplyText(res))
|
||||
}
|
||||
}
|
||||
|
||||
// synthesizeVoiceReply 把助手正文合成音频并定向回发起这条语音的连接
|
||||
// (协同 C1/C3)。三条纪律:
|
||||
// - TTS 失败或降级为空音频时**照旧回帧**并带既有 provider 降级信号,让客户端
|
||||
// 能提示「没生成声音」(绝不是只回一条错误帧就完事);
|
||||
// - 没有助手正文(被取消 / 空回复)时不打一次无谓的 TTS,直接回降级帧 ——
|
||||
// 客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 voice_output;
|
||||
// - 日志只带会话与设备 id,**绝不记正文/音频**。
|
||||
func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, deviceID, transcript, reply string) {
|
||||
if strings.TrimSpace(reply) == "" {
|
||||
reply = res.StopReason
|
||||
h.voiceLog().Warn("voice reply empty", "reason", "turn produced no text", "session", sessionID, "device_id", deviceID)
|
||||
emitVoiceOutput(c, sessionID, deviceID, transcript, "", voice.Audio{}, providerSignal("", true))
|
||||
return
|
||||
}
|
||||
audi, rung, err := ttsWithProvider(ctx, h.voice, reply)
|
||||
if err != nil {
|
||||
h.log.Warn("voice tts", "err", err)
|
||||
h.voiceError(c, "voice synthesis failed")
|
||||
h.voiceLog().Warn("voice tts", "err", err, "session", sessionID, "device_id", deviceID)
|
||||
emitVoiceOutput(c, sessionID, deviceID, transcript, reply, voice.Audio{}, providerSignal("", true))
|
||||
return
|
||||
}
|
||||
emitVoiceOutput(c, sessionID, deviceID, transcript, reply, audi, providerSignal(rung, len(audi.Data) == 0))
|
||||
}
|
||||
|
||||
// emitVoiceOutput 定向回一条 voice_output 帧(只有发起这条语音的连接收得到)。
|
||||
// 载荷在既有四键(audio/format/text/reply)之上**纯增量**地带上 sessionId 与
|
||||
// deviceId(协同 C3):多端同时说话时客户端能区分归属。provider 只在没有真实
|
||||
// 音频时出现(既有降级信号),它不是业务错误。
|
||||
func emitVoiceOutput(c *wsConn, sessionID, deviceID, transcript, reply string, audi voice.Audio, provider string) {
|
||||
data := map[string]any{
|
||||
"audio": base64.StdEncoding.EncodeToString(audi.Data),
|
||||
"format": audi.Format,
|
||||
"text": transcript,
|
||||
"reply": reply,
|
||||
"audio": base64.StdEncoding.EncodeToString(audi.Data),
|
||||
"format": audi.Format,
|
||||
"text": transcript,
|
||||
"reply": reply,
|
||||
"sessionId": sessionID,
|
||||
}
|
||||
if sig := providerSignal(rung, len(audi.Data) == 0); sig != "" {
|
||||
data["provider"] = sig
|
||||
if deviceID != "" {
|
||||
data["deviceId"] = deviceID
|
||||
}
|
||||
if provider != "" {
|
||||
data["provider"] = provider
|
||||
}
|
||||
c.enqueueJSON(wsEvent{Type: wsVoiceOutput, Data: data})
|
||||
}
|
||||
|
||||
// voiceReplyText 返回一个回合的助手正文(没有结果时为空串)。空正文**不拿
|
||||
// stopReason 顶替**:把 "stop"/"idle" 这类内部状态合成成助手声音,客户端会把它
|
||||
// 当回复显示;空正文一律走既有的 provider 降级信号,如实说「没生成声音」。
|
||||
func voiceReplyText(res *loop.Result) string {
|
||||
if res == nil {
|
||||
return ""
|
||||
}
|
||||
return res.Text
|
||||
}
|
||||
|
||||
// voiceLog 返回带语音来源标签的 logger(AGENTS §10:日志必须标来源)。
|
||||
func (h *hub) voiceLog() *slog.Logger {
|
||||
return logger.WithSource(h.log, voiceLogSource)
|
||||
}
|
||||
|
||||
// voiceError 为失败的语音回合回复一个带类型的错误帧。
|
||||
func (h *hub) voiceError(c *wsConn, message string) {
|
||||
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": message}})
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"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/multimodal/voice"
|
||||
)
|
||||
|
||||
// 本文件是协同项 C1(语音回合接进编排器与持久化)与 C3(voice_output 带
|
||||
// sessionId/deviceId)的回归。
|
||||
//
|
||||
// 修前:runVoiceTurn 直调 runner.Run ⇒ 语音问过的事不进历史、不跨端同步、不参与
|
||||
// busy/pending,音频却只能同步等在那个回合上。
|
||||
//
|
||||
// 修后:语音回合与文本回合走同一条路(进编排器 → 落库 → 流式 → 跨端同步),音频
|
||||
// 由回合**结算**时的收尾回调合成并定向回原连接;TTS 失败/降级照旧回帧并带既有
|
||||
// provider 降级信号,空正文不打无谓的 TTS。客户端既有绕行(transcribe → WS chat
|
||||
// → speech)不受影响,因为它们走的是另一条入口。
|
||||
//
|
||||
// 断言一律按帧字段与已落库记录判定;日志/错误不按文本子串断言。
|
||||
|
||||
// waitFrame 排空连接上的帧直到出现给定类型(语音回合的收尾帧会排在若干状态
|
||||
// 信号广播之后),超时即失败。
|
||||
func waitFrame(t *testing.T, c *wsConn, typ string) wsEvent {
|
||||
t.Helper()
|
||||
deadline := time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case b := <-c.send:
|
||||
var ev wsEvent
|
||||
if err := json.Unmarshal(b, &ev); err != nil {
|
||||
t.Fatalf("解码帧 %s: %v", b, err)
|
||||
}
|
||||
if ev.Type == typ {
|
||||
return ev
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("等待 %q 帧超时", typ)
|
||||
return wsEvent{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frameData 返回一帧的载荷 map(形状不对直接判失败)。
|
||||
func frameData(t *testing.T, ev wsEvent) map[string]any {
|
||||
t.Helper()
|
||||
data, ok := ev.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("帧载荷 = %T, want map[string]any(frame=%+v)", ev.Data, ev)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// voiceInputFrame 构造一条 voice_input 上行帧。text 非空时它覆盖 STT(测试因此
|
||||
// 不依赖假 STT 的内容);为空时帧里带一段假音频,走真实的解码 + STT 分支。
|
||||
func voiceInputFrame(text, sessionID, deviceID string) wsInbound {
|
||||
data := map[string]any{
|
||||
"text": text,
|
||||
"format": "wav",
|
||||
"sessionId": sessionID,
|
||||
"deviceId": deviceID,
|
||||
}
|
||||
if text == "" {
|
||||
data["audio"] = base64.StdEncoding.EncodeToString([]byte("fake-audio"))
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return wsInbound{Type: "voice_input", Data: raw}
|
||||
}
|
||||
|
||||
// TestVoiceTurnRepliesWithAudioAndIdentity 钉住 C1 的音频收尾与 C3 的归属字段:
|
||||
// 一条语音回合(走编排器)结算后,定向回一条 voice_output,带助手正文合成的
|
||||
// 音频、识别出的转写,以及 sessionId/deviceId;有真实音频时**不带** provider
|
||||
// 降级信号。
|
||||
func TestVoiceTurnRepliesWithAudioAndIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newOrchestratedTestHub(t, fixedResult("我在听"), nil)
|
||||
hb.voice = &signalVoice{text: "你好昔涟", audio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}, rung: "cloud"}
|
||||
c := newOwnerConn(hb, 64)
|
||||
hb.register(c)
|
||||
|
||||
c.route(voiceInputFrame("", "web", "android:pixel:7a"))
|
||||
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
|
||||
if data["text"] != "你好昔涟" {
|
||||
t.Fatalf("voice_output.text = %v, want 识别出的转写", data["text"])
|
||||
}
|
||||
if data["reply"] != "我在听" {
|
||||
t.Fatalf("voice_output.reply = %v, want 助手正文", data["reply"])
|
||||
}
|
||||
if want := base64.StdEncoding.EncodeToString([]byte("audio-bytes")); data["audio"] != want {
|
||||
t.Fatalf("voice_output.audio = %v, want 合成音频的 base64", data["audio"])
|
||||
}
|
||||
if data["sessionId"] != "web" || data["deviceId"] != "android:pixel:7a" {
|
||||
t.Fatalf("voice_output 缺归属字段:%+v(协同 C3)", data)
|
||||
}
|
||||
if _, present := data["provider"]; present {
|
||||
t.Fatalf("有真实音频时不许带降级信号:%+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceTurnRepliesFrameWhenTTSError 钉住「降级不静默」:TTS 不可用时**照旧
|
||||
// 回帧**(而不是只回一条错误帧让客户端等不到终点),并带既有 provider 降级信号,
|
||||
// 客户端据此提示「没生成声音」。
|
||||
func TestVoiceTurnRepliesFrameWhenTTSError(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newOrchestratedTestHub(t, fixedResult("我在听"), nil)
|
||||
hb.voice = &signalVoice{text: "你好", ttsErr: errors.New("tts down"), rung: "cloud"}
|
||||
c := newOwnerConn(hb, 64)
|
||||
hb.register(c)
|
||||
|
||||
c.route(voiceInputFrame("", "web", "android:pixel:7a"))
|
||||
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
|
||||
if data["provider"] != "silent" {
|
||||
t.Fatalf("TTS 失败时必须带降级信号:%+v", data)
|
||||
}
|
||||
if data["reply"] != "我在听" || data["text"] != "你好" {
|
||||
t.Fatalf("TTS 失败也要把转写与正文带回:%+v", data)
|
||||
}
|
||||
if data["audio"] != "" {
|
||||
t.Fatalf("TTS 失败不该给出音频:%v", data["audio"])
|
||||
}
|
||||
if data["sessionId"] != "web" || data["deviceId"] != "android:pixel:7a" {
|
||||
t.Fatalf("voice_output 缺归属字段:%+v(协同 C3)", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceOutputCarriesIdentityWithoutOrchestrator 钉住未接编排器时的旧直跑
|
||||
// 路径(未接线的构建 / 裸 hub)同样带 C3 的两个归属字段:两条路径的
|
||||
// voice_output 契约必须一致。
|
||||
func TestVoiceOutputCarriesIdentityWithoutOrchestrator(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", StopReason: "stop"}}, nil)
|
||||
hb.voice = &signalVoice{text: "喂", audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"}
|
||||
c := newOwnerConn(hb, 16)
|
||||
hb.register(c)
|
||||
|
||||
c.route(voiceInputFrame("喂", "s9", "android:x"))
|
||||
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
|
||||
if data["sessionId"] != "s9" || data["deviceId"] != "android:x" {
|
||||
t.Fatalf("直跑路径的 voice_output 缺归属字段:%+v(协同 C3)", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceTurnQueuesWhenSessionBusy 钉住 C1 的忙时语义:语音回合与文本回合共用
|
||||
// 编排器,因此会话忙时它照常入有界待处理队列(不插队、不丢),前一条结算后按
|
||||
// **严格 FIFO** 续跑;两条都落库。
|
||||
func TestVoiceTurnQueuesWhenSessionBusy(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(4)
|
||||
hist := &fakeHistory{}
|
||||
hb := newOrchestratedTestHub(t, run.Run, hist)
|
||||
hb.voice = &signalVoice{audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"}
|
||||
c := newOwnerConn(hb, 64)
|
||||
hb.register(c)
|
||||
|
||||
c.route(voiceInputFrame("第一次", "web", "dev"))
|
||||
if !waitBusy(hb.orchestrator, "web") {
|
||||
t.Fatal("会话一直没有忙碌起来")
|
||||
}
|
||||
// 第二条在会话忙时到达:按编排器规则排队,而不是新起一个回合或丢掉。
|
||||
c.route(voiceInputFrame("第二次", "web", "dev"))
|
||||
if !waitQueueDepth(hb.orchestrator, "web", 1) {
|
||||
t.Fatalf("队列深度 = %d, want 1", hb.orchestrator.QueueDepth("web"))
|
||||
}
|
||||
|
||||
// 放行第一条:只有它能结算(第二条还堵在围栏之后),因此这一帧确定属于
|
||||
// 先到的那条 —— FIFO 不是靠时序运气,而是靠门控。
|
||||
run.tickets <- struct{}{}
|
||||
first := frameData(t, waitFrame(t, c, wsVoiceOutput))
|
||||
if first["text"] != "第一次" {
|
||||
t.Fatalf("先到的语音回合 = %v, want 第一次(不得插队)", first["text"])
|
||||
}
|
||||
run.tickets <- struct{}{}
|
||||
second := frameData(t, waitFrame(t, c, wsVoiceOutput))
|
||||
if second["text"] != "第二次" {
|
||||
t.Fatalf("续跑的语音回合 = %v, want 第二次", second["text"])
|
||||
}
|
||||
if !waitIdle(hb.orchestrator, "web") {
|
||||
t.Fatalf("会话一直没有回到空闲:busy=%v depth=%d", hb.orchestrator.Busy("web"), hb.orchestrator.QueueDepth("web"))
|
||||
}
|
||||
|
||||
users := userContents(hist.records())
|
||||
if len(users) != 2 || users[0] != "第一次" || users[1] != "第二次" {
|
||||
t.Fatalf("落库的用户消息 = %v, want [第一次 第二次](语音问过的事必须进历史、且一条不丢)", users)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceSinkFollowsSteeredVoiceMessage 钉住转向注入这条路径(design/15 §2.4):
|
||||
// 忙时入 pending 的语音消息若在回合内边界被注入**同一个回合**(注入=已消费),
|
||||
// 它的音频必须由那个回合结算时一并送回 —— 那条请求的回合已被接手,若不跟着走,
|
||||
// 它会永远等不到 voice_output(音频回复不能丢)。
|
||||
//
|
||||
// 本测试直接驱动认领那一步(consumePending),因为真正的触发点在 loop 的边界回调上,
|
||||
// 而这里要钉的是网关侧的接线:注入时挂上收尾回调、结算时把它派发出去。
|
||||
func TestVoiceSinkFollowsSteeredVoiceMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(4)
|
||||
hb := newOrchestratedTestHub(t, run.Run, nil)
|
||||
hb.voice = &signalVoice{audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"}
|
||||
c := newOwnerConn(hb, 64)
|
||||
hb.register(c)
|
||||
|
||||
// 一个普通(非语音)回合在飞,语音请求在它忙时排队。
|
||||
if sr := hb.orchestrator.Submit(context.Background(), "web", []model.Message{userMsg("文本问题")}, loop.OwnerEnv()); !sr.Immediate {
|
||||
t.Fatalf("文本回合 = %+v, want Immediate", sr)
|
||||
}
|
||||
if !waitBusy(hb.orchestrator, "web") {
|
||||
t.Fatal("会话一直没有忙碌起来")
|
||||
}
|
||||
c.route(voiceInputFrame("语音问题", "web", "dev"))
|
||||
if !waitQueueDepth(hb.orchestrator, "web", 1) {
|
||||
t.Fatalf("队列深度 = %d, want 1", hb.orchestrator.QueueDepth("web"))
|
||||
}
|
||||
|
||||
// 模拟 loop 在回合内边界认领那条 pending 的语音消息(注入=已消费)。
|
||||
o := hb.orchestrator
|
||||
o.mu.Lock()
|
||||
st := o.states["web"]
|
||||
gen, seq := st.gen, st.pending[0].seq
|
||||
o.mu.Unlock()
|
||||
if !o.consumePending("web", gen, map[uint64]bool{seq: true}) {
|
||||
t.Fatal("认领失败:pending 里没有那条语音消息")
|
||||
}
|
||||
|
||||
// 放行那个回合:答案就在它的回复里,因此音频必须回给语音连接。
|
||||
run.tickets <- struct{}{}
|
||||
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
|
||||
if data["text"] != "语音问题" {
|
||||
t.Fatalf("注入后回的音频属于 %v, want 语音问题", data["text"])
|
||||
}
|
||||
}
|
||||
|
||||
// userContents 返回已落库记录里的用户消息正文(按顺序)。
|
||||
func userContents(msgs []history.Message) []string {
|
||||
out := make([]string, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == history.RoleUser {
|
||||
out = append(out, m.Content)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fixedResult 返回一个总是产出同一助手正文的回合驱动器。
|
||||
func fixedResult(text string) TurnRunner {
|
||||
return func(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) {
|
||||
return &loop.Result{Text: text, Steps: 1, StopReason: "stop"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceTurnPersistsIntoHistoryEndToEnd 是 C1 的端到端回归(HTTP/WS 层 +
|
||||
// 真实 history 存储):一条 voice_input 之后,用户消息(转写)与助手回复都真的
|
||||
// 落库,用户消息带发送端的 deviceId 与平台标签,且历史接口查得到 —— 这正是
|
||||
// 「语音问过的事在 Web 上必须看得到」。
|
||||
func TestVoiceTurnPersistsIntoHistoryEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
dataDir := t.TempDir()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
||||
st := newGatewayHistoryStore(t)
|
||||
srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: &stubRunner{res: &loop.Result{Text: "语音答复", StopReason: "stop"}}, History: st})
|
||||
if err != nil {
|
||||
t.Fatalf("构造网关失败: %v", err)
|
||||
}
|
||||
srv.SetVoice(&signalVoice{text: "语音提问", audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"})
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
t.Cleanup(srv.Close)
|
||||
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("签发 token 失败: %v", err)
|
||||
}
|
||||
|
||||
conn := dialWS(t, ts.URL, tok)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
if err := conn.WriteJSON(map[string]any{"type": "voice_input", "data": map[string]any{
|
||||
"audio": base64.StdEncoding.EncodeToString([]byte("raw-audio")),
|
||||
"format": "wav",
|
||||
"sessionId": history.MainSessionID,
|
||||
"deviceId": "android:pixel:7a",
|
||||
}}); err != nil {
|
||||
t.Fatalf("发送 voice_input 失败: %v", err)
|
||||
}
|
||||
if ev := readWSType(t, conn, wsVoiceOutput); ev.Type != wsVoiceOutput {
|
||||
t.Fatalf("帧类型 = %q", ev.Type)
|
||||
}
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("历史接口 status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var envelope struct {
|
||||
Data []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Platform string `json:"platform"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("解析历史响应失败: %v (body=%s)", err, body)
|
||||
}
|
||||
var userOK, assistantOK bool
|
||||
for _, m := range envelope.Data {
|
||||
switch {
|
||||
case m.Role == history.RoleUser && m.Content == "语音提问":
|
||||
userOK = true
|
||||
if m.DeviceID != "android:pixel:7a" {
|
||||
t.Fatalf("用户消息 deviceId = %q, want android:pixel:7a(语音特有身份必须落库)", m.DeviceID)
|
||||
}
|
||||
if m.Platform != history.PlatformWeb {
|
||||
t.Fatalf("用户消息 platform = %q, want %q", m.Platform, history.PlatformWeb)
|
||||
}
|
||||
case m.Role == history.RoleAssistant && m.Content == "语音答复":
|
||||
assistantOK = true
|
||||
}
|
||||
}
|
||||
if !userOK || !assistantOK {
|
||||
t.Fatalf("历史里缺语音回合:user=%v assistant=%v (msgs=%+v)", userOK, assistantOK, envelope.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// readWSType 从真连接上读帧直到出现给定类型。
|
||||
func readWSType(t *testing.T, conn *websocket.Conn, typ string) wsEvent {
|
||||
t.Helper()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
for {
|
||||
var ev wsEvent
|
||||
if err := conn.ReadJSON(&ev); err != nil {
|
||||
t.Fatalf("读 WS 帧失败(等 %q): %v", typ, err)
|
||||
}
|
||||
if ev.Type == typ {
|
||||
return ev
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,19 @@ func (c *wsConn) shutdown() {
|
||||
})
|
||||
}
|
||||
|
||||
// closed 报告连接是否已被关闭(readPump 退出时 shutdown 关闭 done)。异步收尾
|
||||
// 路径(如语音回合结算后的定向回帧)用它判断「这条回帧还送得到吗」—— send 通道
|
||||
// 不会在关闭时被关上,入队后只会静默丢失,所以必须显式问一次,才能给出一条带
|
||||
// 来源的 WARN 而不是无声无息。nil 字段(零值 wsConn)视为未关闭。
|
||||
func (c *wsConn) closed() bool {
|
||||
select {
|
||||
case <-c.done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue 把序列化后的帧入队,慢客户端缓冲满时丢弃它,
|
||||
// 使生产者绝不阻塞。
|
||||
func (c *wsConn) enqueue(b []byte) {
|
||||
|
||||
@@ -55,8 +55,9 @@ func (c *wsConn) route(in wsInbound) {
|
||||
c.handleInterrupt(in.Data)
|
||||
case "voice_input":
|
||||
// 语音经注入的 STT→chat→TTS 管线处理。没有 voice service 时回一个
|
||||
// 明确的错误,而不是把帧丢掉。音频回复作为 voice_output 事件发在
|
||||
// 发起请求的那根连接上。
|
||||
// 明确的错误,而不是把帧丢掉。转写之后的**回合**与文本同一条路(协同
|
||||
// C1:编排器 → 落库 → 流式 → 跨端同步),音频回复在回合结算时作为
|
||||
// voice_output 事件定向回发起请求的那根连接。
|
||||
c.handleVoiceInput(in)
|
||||
case "subagent_ask":
|
||||
c.handleSubagentAsk(in)
|
||||
@@ -152,6 +153,28 @@ func claimRole(claims *auth.Claims) string {
|
||||
return string(claims.Role)
|
||||
}
|
||||
|
||||
// wsChatBusyCode 是待处理队列满时定向回发送者的机器可读错误码。它与 REST
|
||||
// 同步/流式路径的 409 `chat_busy` **同码**:客户端用同一个 code 处理两条入口的
|
||||
// 「队列满」,不必为 WS 再造一套判定(协同 G5)。
|
||||
const wsChatBusyCode = "chat_busy"
|
||||
|
||||
// notifyPendingFull 定向回一条拒绝帧,告诉发送这条消息的连接「会话正忙、待处理
|
||||
// 消息已满、本条未入队」,并带上能对齐本地乐观气泡的键(协同 G5):clientMsgId
|
||||
// 原样带回(未携带该键时不伪造),messageId 只在该键此前已落库/已认领时出现。
|
||||
//
|
||||
// 它只到发送者、不广播:广播的 pending_full 是状态信号,其余连接按 clientMsgId
|
||||
// 自行忽略即可。
|
||||
func (c *wsConn) notifyPendingFull(sessionID, clientMsgID, messageID string) {
|
||||
data := map[string]any{"code": wsChatBusyCode, "message": "会话正忙,待处理消息已满,本条未入队", "sessionId": sessionID}
|
||||
if clientMsgID != "" {
|
||||
data["clientMsgId"] = clientMsgID
|
||||
}
|
||||
if messageID != "" {
|
||||
data["messageId"] = messageID
|
||||
}
|
||||
c.enqueueJSON(wsEvent{Type: "error", Data: data})
|
||||
}
|
||||
|
||||
// submitChat 把一条 chat 上行帧直接交给会话编排器(design/15 §2.1/§2.2),
|
||||
// 而不是经 bus 转发:会话消息不得依赖可丢弃的通道(见 route 的 chat 分支)。
|
||||
// 解码、角色解析与用户消息落库都在该 goroutine 上完成,因此读泵绝不等待
|
||||
@@ -168,6 +191,8 @@ func (c *wsConn) submitChat(payload any) {
|
||||
return
|
||||
}
|
||||
// 回合自身的取消由编排器持有(Interrupt/InterruptAll 取消它),
|
||||
// 因此这里的基 ctx 只需是存活到回合结束的背景上下文。
|
||||
go c.h.streamChat(context.Background(), payload)
|
||||
// 因此这里的基 ctx 只需是存活到回合结束的背景上下文。队列满的拒绝要定向回
|
||||
// **这条**连接(协同 G5):编排器只认 ctx 上给出的回调(REST 走 409,不接)。
|
||||
ctx := withPendingFullNotifier(context.Background(), c.notifyPendingFull)
|
||||
go c.h.streamChat(ctx, payload)
|
||||
}
|
||||
Reference in new issue
Block a user