feat(gateway): 语音回合进编排+落库、G5 队列满带回身份、voice_output 归属(安卓 G5/C1/C3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-04 17:59:28 +08:00
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)
+111
View File
@@ -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)
}
}
+114 -26
View File
@@ -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
}
}
}
+13
View File
@@ -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) {
+29 -4
View File
@@ -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)
}