Files
Cyrene_v2/internal/services/gateway/session_parallel_turn_interrupt_test.go
T
AskaEth 5de708a560
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs:
  把未被 <p> 包裹的段落补上 <p>(按空行分段;<action>/<narration> 段也包进 <p>,
  与正确形态 <p><action>…</action>…</p> 一致)
- **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工)
- 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、
  proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
2026-10-05 10:56:39 +08:00

233 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"encoding/json"
"slices"
"testing"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
)
// 本文件是 design/15 §2.5.4「`interrupt` 增可选 `turnId`」在**真实 WS 路由**上的
// 端到端回归(忙时并行规范草案 §2.3):真 chat 上行帧 → 真编排器(真 pending /
// 真额度判定)→ 真帧门 → 真 JWT 连接,只有模型适配器是脚本化的。
//
// 两条断言:
// - 只停目标路:另一路照常收尾并落库,被取消的那一路不补说明;
// - 目标不存在:定向回 bad_request,且**一路都不动**(绝不静默降级成「停全部」)。
//
// `turn_begin` 的帧层断言在 session_parallel_frames_test.go(编排器侧)。
// turnBeginWire 是 `turn_begin` 帧的线上视图(帧壳 + 载荷,与客户端解码同形)。
type turnBeginWire struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
TurnID string `json:"turnId"`
Data struct {
Kind string `json:"kind"`
MessageIDs []string `json:"messageIds"`
Leg int `json:"leg"`
ActiveTurns int `json:"activeTurns"`
} `json:"data"`
}
// interruptTurnWire 是 `session_interrupted` 帧的线上视图(含两个计数键)。
type interruptTurnWire struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
Data struct {
SessionID string `json:"sessionId"`
Mode string `json:"mode"`
Dropped int `json:"dropped"`
CancelledTurns int `json:"cancelledTurns"`
ActiveTurns int `json:"activeTurns"`
} `json:"data"`
}
// turnFrameFixture 是一次「忙时两路」场景的生产依赖:脚本化 runner(停在 Run 里,
// 直到被放行或被取消)+ 真编排器 + 真 WS 连接 + 记录型历史。
type turnFrameFixture struct {
srv *Server
o *Orchestrator
run *a2Runner
hist *fakeHistory
conn *websocket.Conn
}
// newTurnFrameFixture 拨一条 owner 连接并接线一个默认额度(2 路)的编排器。
func newTurnFrameFixture(t *testing.T) *turnFrameFixture {
t.Helper()
run := newA2Runner()
hist := &fakeHistory{}
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist})
conn := dialWS(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner))
t.Cleanup(func() { _ = conn.Close() })
waitConnCount(t, srv, 1)
return &turnFrameFixture{srv: srv, o: srv.hub.orchestrator, run: run, hist: hist, conn: conn}
}
// startChat 写下一条 chat 上行帧(可带并行声明)。
func (f *turnFrameFixture) startChat(t *testing.T, text string, parallel bool) {
t.Helper()
payload := map[string]any{"sessionId": "web", "text": text}
if parallel {
payload["parallel"] = true
}
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("编码 chat 帧: %v", err)
}
if err := f.conn.WriteJSON(wsInbound{Type: "chat", Data: b}); err != nil {
t.Fatalf("write chat %s: %v", text, err)
}
}
// beginOneLeg 发一条 chat 帧并读回它的 `turn_begin` 帧(期间的状态帧与持久化帧
// 一并跳过)。
func (f *turnFrameFixture) beginOneLeg(t *testing.T, text string, parallel bool) turnBeginWire {
t.Helper()
f.startChat(t, text, parallel)
raw := readFrameOfType(t, f.conn, EventTurnBegin, "chat "+text)
var begin turnBeginWire
if err := json.Unmarshal(raw, &begin); err != nil {
t.Fatalf("解 turn_begin 帧 %s: %v", raw, err)
}
if begin.TurnID == "" {
t.Fatalf("turn_begin 帧壳没有 turnId:%s", raw)
}
return begin
}
// interruptTurn 发一条 `interrupt{scope:"current", turnId}` 上行帧。
func (f *turnFrameFixture) interruptTurn(t *testing.T, turnID string) {
t.Helper()
b, err := json.Marshal(map[string]string{
"sessionId": "web",
"scope": string(InterruptScopeCurrent),
"turnId": turnID,
})
if err != nil {
t.Fatalf("编码 interrupt 帧: %v", err)
}
if err := f.conn.WriteJSON(wsInbound{Type: "interrupt", Data: b}); err != nil {
t.Fatalf("write interrupt %s: %v", turnID, err)
}
}
// twoLegsUp 起满两路(第一条普通、第二条显式并行),等到两路都进了 runner,并
// 断言两帧 turn_begin 的 kind / leg / activeTurns(帧壳 turnId 由此取得)。
func (f *turnFrameFixture) twoLegsUp(t *testing.T) (turnBeginWire, turnBeginWire) {
t.Helper()
first := f.beginOneLeg(t, "A", false)
waitUntil(t, "第一路进入模型调用", func() bool { return f.run.count() == 1 })
second := f.beginOneLeg(t, "B", true)
waitUntil(t, "两路都在跑", func() bool { return f.run.count() == 2 && f.o.ActiveTurns("web") == 2 })
if first.Data.Kind != turnBeginKindTurn || first.Data.Leg != 1 || first.Data.ActiveTurns != 1 {
t.Fatalf("第一路 turn_begin = %+v, want {kind:turn, leg:1, activeTurns:1}", first.Data)
}
if second.Data.Kind != turnBeginKindParallel || second.Data.Leg != 2 || second.Data.ActiveTurns != 2 {
t.Fatalf("第二路 turn_begin = %+v, want {kind:parallel, leg:2, activeTurns:2}", second.Data)
}
if first.Data.MessageIDs == nil || len(first.Data.MessageIDs) != 1 {
t.Fatalf("第一路 messageIds = %v, want 恰好一条用户消息 id", first.Data.MessageIDs)
}
if len(second.Data.MessageIDs) != 1 || second.Data.MessageIDs[0] == first.Data.MessageIDs[0] {
t.Fatalf("第二路 messageIds = %v,第一路 = %v:两路必须各钉各的消息",
second.Data.MessageIDs, first.Data.MessageIDs)
}
return first, second
}
// TestInterruptTurnOverWSStopsOnlyTargetLeg 钉住④的端到端:`interrupt{scope:
// "current", turnId}` 只停那一路 —— 另一路仍在飞,且随后照常跑完并落库;被取消
// 的第二路不补说明(现状语义);帧上 cancelledTurns=1、dropped=0,且本次打断
// **不回错误帧**、只上报一次。
func TestInterruptTurnOverWSStopsOnlyTargetLeg(t *testing.T) {
t.Parallel()
f := newTurnFrameFixture(t)
_, second := f.twoLegsUp(t)
f.interruptTurn(t, second.TurnID)
raw := readFrameOfType(t, f.conn, EventSessionInterrupt, "interrupt turnId")
var frame interruptTurnWire
if err := json.Unmarshal(raw, &frame); err != nil {
t.Fatalf("解 session_interrupted 帧 %s: %v", raw, err)
}
if frame.Data.CancelledTurns != 1 {
t.Fatalf("cancelledTurns = %d, want 1(只停目标那一路)frame=%s", frame.Data.CancelledTurns, raw)
}
if frame.Data.Dropped != 0 {
t.Fatalf("dropped = %d, want 0(目标化打断不碰待处理队列)", frame.Data.Dropped)
}
if frame.Data.Mode == string(ModeIdle) {
t.Fatalf("mode = %q, want 非 idle(另一路仍在飞)", frame.Data.Mode)
}
waitUntil(t, "被取消的第二路收尾", func() bool { return f.o.ActiveTurns("web") == 1 })
// 另一路仍在跑:两路都进过 Run,而第一路此刻还停在它的 Run 里。
if got := f.run.count(); got != 2 {
t.Fatalf("被驱动的回合数 = %d, want 2", got)
}
if got := f.run.texts(); !slices.Equal(got, []string{"A", "B"}) {
t.Fatalf("已驱动 = %v, want [A B]", got)
}
close(f.run.release)
waitUntil(t, "会话排空", func() bool { return !f.o.Busy("web") && f.o.QueueDepth("web") == 0 })
rows := assistantContents(f.hist.records())
if !slices.Equal(rows, []string{storedParagraph("reply-A")}) {
t.Fatalf("助手回复 = %v, want [<p>reply-A</p>](另一路继续作答;被取消路不补说明)", rows)
}
types := a2DrainTypes(t, f.conn, a2Settle)
if containsFrameType(types, "error") {
t.Fatalf("只停某一路不该回错误帧,实际帧序列 %v", types)
}
if containsFrameType(types, EventSessionInterrupt) {
t.Fatalf("一次 interrupt 只应上报一次 session_interrupted,实际帧序列 %v", types)
}
}
// TestInterruptUnknownTurnIDIsDirectedErrorAndSparesAllLegs 钉住④的报错侧:
// `turnId` 指向不存在的路 ⇒ 定向回 `{code:"bad_request"}`,**且一路都不动** ——
// 两路随后都照常收尾并各自落一行回复(被误取消的那一路不会有回复),全程不发
// session_interrupted。静默降级成「停全部」会让用户丢掉他并不想停的那一路,
// 这正是本用例要挡住的事。
func TestInterruptUnknownTurnIDIsDirectedErrorAndSparesAllLegs(t *testing.T) {
t.Parallel()
f := newTurnFrameFixture(t)
f.twoLegsUp(t)
f.interruptTurn(t, "turn-does-not-exist")
raw := readFrameOfType(t, f.conn, "error", "unknown turnId")
var errFrame struct {
Data struct {
Code string `json:"code"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &errFrame); err != nil {
t.Fatalf("解 error 帧 %s: %v", raw, err)
}
if errFrame.Data.Code != wsBadRequestCode {
t.Fatalf("error.code = %q, want %q(显式报错,绝不静默当默认执行)", errFrame.Data.Code, wsBadRequestCode)
}
if got := f.o.ActiveTurns("web"); got != 2 {
t.Fatalf("目标不存在时在途路数 = %d, want 2(绝不误伤)", got)
}
close(f.run.release)
waitUntil(t, "会话排空", func() bool { return !f.o.Busy("web") && f.o.QueueDepth("web") == 0 })
rows := assistantContents(f.hist.records())
slices.Sort(rows)
if want := []string{storedParagraph("reply-A"), storedParagraph("reply-B")}; !slices.Equal(rows, want) {
t.Fatalf("助手回复 = %v, want %v(两路都必须照常作答)", rows, want)
}
types := a2DrainTypes(t, f.conn, a2Settle)
if containsFrameType(types, EventSessionInterrupt) {
t.Fatalf("目标不存在的打断不得取消任何一路,实际帧序列 %v", types)
}
}